Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
708bdf6be0 | ||
|
|
5917fa88ad | ||
|
|
8ddeb7b377 | ||
|
|
f480905bb9 | ||
|
|
18398e559c | ||
|
|
1583ef2a17 | ||
|
|
d893373bd9 | ||
|
|
cb575f9a82 | ||
|
|
6b96f1ffb1 | ||
|
|
0784553017 | ||
|
|
e4c3c92cab | ||
|
|
0abc1c6b02 | ||
|
|
5bf4106db2 | ||
|
|
2432e9a17f | ||
|
|
e3f8f14f6a | ||
|
|
0376e71066 | ||
|
|
8ef2cbe68e | ||
|
|
b75bf9bb30 | ||
|
|
25d3d553ff | ||
|
|
c11565aaf8 | ||
|
|
2b7a89174a |
@@ -46,7 +46,7 @@ COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
|
||||
COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||
COPY --from=builder /app/node_modules/stripe ./node_modules/stripe
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@3.4.4 qrcode@1.5.4 --no-save
|
||||
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save
|
||||
COPY server-custom.js ./server-custom.js
|
||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -31,15 +31,27 @@ const nextConfig = {
|
||||
key: 'Cross-Origin-Opener-Policy',
|
||||
value: 'same-origin',
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
{
|
||||
key: 'X-DNS-Prefetch-Control',
|
||||
value: 'on',
|
||||
},
|
||||
{
|
||||
key: 'X-XSS-Protection',
|
||||
value: '1; mode=block',
|
||||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com http://localhost:9000 http://minio:9000",
|
||||
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com https://server.arcgisonline.com https://*.geo.admin.ch http://localhost:9000 http://minio:9000",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://api.open-meteo.com",
|
||||
"connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://nominatim.openstreetmap.org https://api.open-meteo.com https://server.arcgisonline.com https://*.geo.admin.ch",
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
|
||||
394
package-lock.json
generated
394
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lageplan",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
@@ -26,7 +26,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"@react-pdf/renderer": "^3.4.4",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -53,7 +53,8 @@
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -2878,176 +2879,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/fns": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-2.2.1.tgz",
|
||||
"integrity": "sha512-s78aDg0vDYaijU5lLOCsUD+qinQbfOvcNeaoX9AiE7+kZzzCo6B/nX+l48cmt9OosJmvZvE9DWR9cLhrhOi2pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/font": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-2.5.2.tgz",
|
||||
"integrity": "sha512-Ud0EfZ2FwrbvwAWx8nz+KKLmiqACCH9a/N/xNDOja0e/YgSnqTpuyHegFBgIMKjuBtO5dNvkb4dXkxAhGe/ayw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/types": "^2.6.0",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"fontkit": "^2.0.2",
|
||||
"is-url": "^1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/image": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-2.3.6.tgz",
|
||||
"integrity": "sha512-7iZDYZrZlJqNzS6huNl2XdMcLFUo68e6mOdzQeJ63d5eApdthhSHBnkGzHfLhH5t8DCpZNtClmklzuLL63ADfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/png-js": "^2.3.1",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"jay-peg": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-3.13.0.tgz",
|
||||
"integrity": "sha512-lpPj/EJYHFOc0ALiJwLP09H28B4ADyvTjxOf67xTF+qkWd+dq1vg7dw3wnYESPnWk5T9NN+HlUenJqdYEY9AvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "2.2.1",
|
||||
"@react-pdf/image": "^2.3.6",
|
||||
"@react-pdf/pdfkit": "^3.2.0",
|
||||
"@react-pdf/primitives": "^3.1.1",
|
||||
"@react-pdf/stylesheet": "^4.3.0",
|
||||
"@react-pdf/textkit": "^4.4.1",
|
||||
"@react-pdf/types": "^2.6.0",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"queue": "^6.0.1",
|
||||
"yoga-layout": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/pdfkit": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-3.2.0.tgz",
|
||||
"integrity": "sha512-OBfCcnTC6RpD9uv9L2woF60Zj1uQxhLFzTBXTdcYE9URzPE/zqXIyzpXEA4Vf3TFbvBCgFE2RzJ2ZUS0asq7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/png-js": "^2.3.1",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.2",
|
||||
"jay-peg": "^1.0.2",
|
||||
"vite-compatible-readable-stream": "^3.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/png-js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-2.3.1.tgz",
|
||||
"integrity": "sha512-pEZ18I4t1vAUS4lmhvXPmXYP4PHeblpWP/pAlMMRkEyP7tdAeHUN7taQl9sf9OPq7YITMY3lWpYpJU6t4CZgZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/primitives": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-3.1.1.tgz",
|
||||
"integrity": "sha512-miwjxLwTnO3IjoqkTVeTI+9CdyDggwekmSLhVCw+a/7FoQc+gF3J2dSKwsHvAcVFM0gvU8mzCeTofgw0zPDq0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/render": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-3.5.0.tgz",
|
||||
"integrity": "sha512-gFOpnyqCgJ6l7VzfJz6rG1i2S7iVSD8bUHDjPW9Mze8TmyksHzN2zBH3y7NbsQOw1wU6hN4NhRmslrsn+BRDPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "2.2.1",
|
||||
"@react-pdf/primitives": "^3.1.1",
|
||||
"@react-pdf/textkit": "^4.4.1",
|
||||
"@react-pdf/types": "^2.6.0",
|
||||
"abs-svg-path": "^0.1.1",
|
||||
"color-string": "^1.9.1",
|
||||
"normalize-svg-path": "^1.1.0",
|
||||
"parse-svg-path": "^0.1.2",
|
||||
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/renderer": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-3.4.5.tgz",
|
||||
"integrity": "sha512-O1N8q45bTs7YuC+x9afJSKQWDYQy2RjoCxlxEGdbCwP+WD5G6dWRUWXlc8F0TtzU3uFglYMmDab2YhXTmnVN9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/font": "^2.5.2",
|
||||
"@react-pdf/layout": "^3.13.0",
|
||||
"@react-pdf/pdfkit": "^3.2.0",
|
||||
"@react-pdf/primitives": "^3.1.1",
|
||||
"@react-pdf/render": "^3.5.0",
|
||||
"@react-pdf/types": "^2.6.0",
|
||||
"events": "^3.3.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"queue": "^6.0.1",
|
||||
"scheduler": "^0.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/stylesheet": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-4.3.0.tgz",
|
||||
"integrity": "sha512-x7IVZOqRrUum9quuDeFXBveXwBht+z/6B0M+z4a4XjfSg1vZVvzoTl07Oa1yvQ/4yIC5yIkG2TSMWeKnDB+hrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "2.2.1",
|
||||
"@react-pdf/types": "^2.6.0",
|
||||
"color-string": "^1.9.1",
|
||||
"hsl-to-hex": "^1.0.0",
|
||||
"media-engine": "^1.0.3",
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/textkit": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-4.4.1.tgz",
|
||||
"integrity": "sha512-Jl9wdTqIvJ5pX+vAGz0EOhP7ut5Two9H6CzTKo/YYPeD79cM2yTXF3JzTERBC28y7LR0Waq9D2LHQjI+b/EYUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "2.2.1",
|
||||
"bidi-js": "^1.0.2",
|
||||
"hyphen": "^1.6.4",
|
||||
"unicode-properties": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz",
|
||||
"integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/font": "^4.0.4",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/stylesheet": "^6.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types/node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
|
||||
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/types/node_modules/@react-pdf/font": {
|
||||
"node_modules/@react-pdf/font": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.4.tgz",
|
||||
"integrity": "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==",
|
||||
@@ -3059,7 +2896,34 @@
|
||||
"is-url": "^1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types/node_modules/@react-pdf/pdfkit": {
|
||||
"node_modules/@react-pdf/image": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.4.tgz",
|
||||
"integrity": "sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/png-js": "^3.0.0",
|
||||
"jay-peg": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.2.tgz",
|
||||
"integrity": "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"@react-pdf/image": "^3.0.4",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/stylesheet": "^6.1.2",
|
||||
"@react-pdf/textkit": "^6.1.0",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"emoji-regex-xs": "^1.0.0",
|
||||
"queue": "^6.0.1",
|
||||
"yoga-layout": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/pdfkit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz",
|
||||
"integrity": "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==",
|
||||
@@ -3075,7 +2939,7 @@
|
||||
"vite-compatible-readable-stream": "^3.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types/node_modules/@react-pdf/png-js": {
|
||||
"node_modules/@react-pdf/png-js": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
|
||||
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
|
||||
@@ -3084,13 +2948,68 @@
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types/node_modules/@react-pdf/primitives": {
|
||||
"node_modules/@react-pdf/primitives": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
|
||||
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/types/node_modules/@react-pdf/stylesheet": {
|
||||
"node_modules/@react-pdf/reconciler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/render": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.2.tgz",
|
||||
"integrity": "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/textkit": "^6.1.0",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"abs-svg-path": "^0.1.1",
|
||||
"color-string": "^1.9.1",
|
||||
"normalize-svg-path": "^1.1.0",
|
||||
"parse-svg-path": "^0.1.2",
|
||||
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/renderer": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.2.tgz",
|
||||
"integrity": "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"@react-pdf/font": "^4.0.4",
|
||||
"@react-pdf/layout": "^4.4.2",
|
||||
"@react-pdf/pdfkit": "^4.1.0",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/reconciler": "^2.0.0",
|
||||
"@react-pdf/render": "^4.3.2",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"events": "^3.3.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"queue": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/stylesheet": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.2.tgz",
|
||||
"integrity": "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==",
|
||||
@@ -3104,6 +3023,29 @@
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/textkit": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.0.tgz",
|
||||
"integrity": "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"bidi-js": "^1.0.2",
|
||||
"hyphen": "^1.6.4",
|
||||
"unicode-properties": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz",
|
||||
"integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/font": "^4.0.4",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/stylesheet": "^6.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -4753,15 +4695,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5082,10 +5015,10 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"node_modules/emoji-regex-xs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
@@ -7747,26 +7680,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -9043,14 +8956,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.17.0.tgz",
|
||||
"integrity": "sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"object-assign": "^4.1.1"
|
||||
}
|
||||
"version": "0.25.0-rc-603e6108-20241029",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selecto": {
|
||||
"version": "1.26.3",
|
||||
@@ -9897,12 +9806,6 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
@@ -10346,22 +10249,6 @@
|
||||
"@zxing/text-encoding": "0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -10667,9 +10554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-2.0.1.tgz",
|
||||
"integrity": "sha512-tT/oChyDXelLo2A+UVnlW9GU7CsvFMaEnd9kVFsaiCQonFAXd3xrHhkLYu+suwwosrAEQ746xBU+HvYtm1Zs2Q==",
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
@@ -10680,6 +10567,35 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.1",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -34,7 +34,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"@react-pdf/renderer": "^3.4.4",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -61,7 +61,8 @@
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
|
||||
@@ -214,6 +214,23 @@ async function migrate() {
|
||||
console.log(' Privacy consent columns skipped:', e.message)
|
||||
}
|
||||
|
||||
// ─── Step 12: Create tenant_symbols table ───
|
||||
console.log(' [12] Creating tenant_symbols table...')
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS tenant_symbols (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
"iconId" TEXT NOT NULL REFERENCES icon_assets(id) ON DELETE CASCADE,
|
||||
UNIQUE("tenantId", "iconId")
|
||||
)
|
||||
`)
|
||||
console.log(' tenant_symbols table created (or already exists)')
|
||||
} catch (e) {
|
||||
console.log(' tenant_symbols table skipped:', e.message)
|
||||
}
|
||||
|
||||
console.log('✅ Database migrations complete')
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ model Tenant {
|
||||
checkTemplates JournalCheckTemplate[]
|
||||
iconCategories IconCategory[]
|
||||
iconAssets IconAsset[]
|
||||
tenantSymbols TenantSymbol[]
|
||||
upgradeRequests UpgradeRequest[]
|
||||
dictionaryEntries DictionaryEntry[]
|
||||
rapports Rapport[]
|
||||
@@ -236,6 +237,8 @@ model IconAsset {
|
||||
tenantId String?
|
||||
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
||||
|
||||
tenantSymbols TenantSymbol[]
|
||||
|
||||
@@map("icon_assets")
|
||||
}
|
||||
|
||||
@@ -375,6 +378,24 @@ model UpgradeRequest {
|
||||
@@map("upgrade_requests")
|
||||
}
|
||||
|
||||
// ─── Tenant Symbol Collection ─────────────────────────────
|
||||
|
||||
model TenantSymbol {
|
||||
id String @id @default(uuid())
|
||||
customName String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
iconId String
|
||||
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("tenant_symbols")
|
||||
}
|
||||
|
||||
// ─── Dictionary (Global + Tenant word library) ────────────
|
||||
|
||||
model DictionaryEntry {
|
||||
|
||||
@@ -252,32 +252,8 @@ async function main() {
|
||||
console.log('✅ Hose types created:', hoseTypes.length)
|
||||
|
||||
// ─── Journal Check Templates (SOMA) ─────────────────────
|
||||
const somaTemplates = [
|
||||
{ label: 'Stopp Zutritt / Ex-Gefahr', sortOrder: 1 },
|
||||
{ label: 'Alarmierung Gesamt-Fw', sortOrder: 2 },
|
||||
{ label: 'Alarmierung Ofw Wohlen', sortOrder: 3 },
|
||||
{ label: 'Alarmierung Stp Baden', sortOrder: 4 },
|
||||
{ label: 'Alarmierung Ambulanz', sortOrder: 5 },
|
||||
{ label: 'Alarmierung BWL', sortOrder: 6 },
|
||||
{ label: 'Alarmierung BL/Meister', sortOrder: 7 },
|
||||
{ label: 'Brunnenchef Villmergen', sortOrder: 8 },
|
||||
{ label: 'Berieselung Tank / Anl', sortOrder: 9 },
|
||||
{ label: 'Druckerhöhung', sortOrder: 10 },
|
||||
]
|
||||
|
||||
for (const tpl of somaTemplates) {
|
||||
await prisma.journalCheckTemplate.upsert({
|
||||
where: { id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() },
|
||||
update: { label: tpl.label, sortOrder: tpl.sortOrder },
|
||||
create: {
|
||||
id: tpl.label.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(),
|
||||
label: tpl.label,
|
||||
sortOrder: tpl.sortOrder,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log('✅ SOMA templates created:', somaTemplates.length)
|
||||
// No default SOMA templates — each tenant defines their own via Admin → SOMA tab
|
||||
console.log('ℹ️ SOMA templates: keine Standard-Vorgaben (Tenants konfigurieren eigene)')
|
||||
console.log('🎉 Seed completed successfully!')
|
||||
}
|
||||
|
||||
|
||||
BIN
public/logo-icon-maskable.png
Normal file
BIN
public/logo-icon-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -20,7 +20,13 @@
|
||||
"src": "/logo-icon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/logo-icon-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [],
|
||||
|
||||
121
public/sw.js
121
public/sw.js
@@ -1,6 +1,10 @@
|
||||
const TILE_CACHE = 'lageplan-tiles-v2'
|
||||
const STATIC_CACHE = 'lageplan-static-v2'
|
||||
const APP_CACHE = 'lageplan-app-v2'
|
||||
const TILE_CACHE = 'lageplan-tiles-v3'
|
||||
const STATIC_CACHE = 'lageplan-static-v3'
|
||||
const APP_CACHE = 'lageplan-app-v3'
|
||||
const API_CACHE = 'lageplan-api-v3'
|
||||
|
||||
// API routes that should be cached for offline use
|
||||
const CACHEABLE_API = ['/api/icons', '/api/hose-types', '/api/dictionary']
|
||||
|
||||
// Pre-cache essential app shell on install
|
||||
self.addEventListener('install', (event) => {
|
||||
@@ -8,6 +12,8 @@ self.addEventListener('install', (event) => {
|
||||
caches.open(APP_CACHE).then((cache) =>
|
||||
cache.addAll([
|
||||
'/app',
|
||||
'/login',
|
||||
'/',
|
||||
'/logo.svg',
|
||||
'/logo-icon.png',
|
||||
'/manifest.json',
|
||||
@@ -17,7 +23,6 @@ self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// Cache strategy: Network First for API, Cache First for tiles, Stale While Revalidate for static assets
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = event.request.url
|
||||
const { pathname } = new URL(url)
|
||||
@@ -25,19 +30,71 @@ self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return
|
||||
|
||||
// API requests: network only (don't cache dynamic data)
|
||||
// Never intercept Socket.IO — let it pass through directly
|
||||
if (pathname.startsWith('/socket.io')) return
|
||||
|
||||
// Cacheable API routes: Network First with cache fallback (icons, hose-types, dictionary)
|
||||
if (CACHEABLE_API.some(p => pathname.startsWith(p))) {
|
||||
event.respondWith(
|
||||
caches.open(API_CACHE).then((cache) =>
|
||||
fetch(event.request).then((response) => {
|
||||
if (response.ok) cache.put(event.request, response.clone())
|
||||
return response
|
||||
}).catch(() =>
|
||||
cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', {
|
||||
status: 503, headers: { 'Content-Type': 'application/json' }
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Projects API: Network First with cache fallback
|
||||
if (pathname === '/api/projects' || pathname.match(/^\/api\/projects\/[^/]+$/)) {
|
||||
event.respondWith(
|
||||
caches.open(API_CACHE).then((cache) =>
|
||||
fetch(event.request).then((response) => {
|
||||
if (response.ok) cache.put(event.request, response.clone())
|
||||
return response
|
||||
}).catch(() =>
|
||||
cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', {
|
||||
status: 503, headers: { 'Content-Type': 'application/json' }
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Features API: Network First with cache fallback
|
||||
if (pathname.match(/^\/api\/projects\/[^/]+\/features$/)) {
|
||||
event.respondWith(
|
||||
caches.open(API_CACHE).then((cache) =>
|
||||
fetch(event.request).then((response) => {
|
||||
if (response.ok) cache.put(event.request, response.clone())
|
||||
return response
|
||||
}).catch(() =>
|
||||
cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', {
|
||||
status: 503, headers: { 'Content-Type': 'application/json' }
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Other API requests: network only
|
||||
if (pathname.startsWith('/api/')) return
|
||||
|
||||
// Cache map tiles from OpenStreetMap (Cache First)
|
||||
if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com')) {
|
||||
// Cache map tiles from OSM / MapTiler / ArcGIS / Swisstopo (Cache First — tiles don't change)
|
||||
if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com') || url.includes('server.arcgisonline.com') || url.includes('geo.admin.ch')) {
|
||||
event.respondWith(
|
||||
caches.open(TILE_CACHE).then((cache) =>
|
||||
cache.match(event.request).then((cached) => {
|
||||
if (cached) return cached
|
||||
return fetch(event.request).then((response) => {
|
||||
if (response.ok) {
|
||||
cache.put(event.request, response.clone())
|
||||
}
|
||||
if (response.ok) cache.put(event.request, response.clone())
|
||||
return response
|
||||
}).catch(() => new Response('', { status: 503 }))
|
||||
})
|
||||
@@ -46,7 +103,23 @@ self.addEventListener('fetch', (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Static assets (JS, CSS, images): Stale While Revalidate
|
||||
// Next.js build chunks (_next/static): Cache First (hashed filenames = immutable)
|
||||
if (pathname.startsWith('/_next/static/')) {
|
||||
event.respondWith(
|
||||
caches.open(STATIC_CACHE).then((cache) =>
|
||||
cache.match(event.request).then((cached) => {
|
||||
if (cached) return cached
|
||||
return fetch(event.request).then((response) => {
|
||||
if (response.ok) cache.put(event.request, response.clone())
|
||||
return response
|
||||
}).catch(() => new Response('', { status: 503 }))
|
||||
})
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Other static assets (JS, CSS, images, fonts): Stale While Revalidate
|
||||
if (pathname.match(/\.(js|css|png|jpg|jpeg|svg|ico|woff2?)$/)) {
|
||||
event.respondWith(
|
||||
caches.open(STATIC_CACHE).then((cache) =>
|
||||
@@ -62,8 +135,8 @@ self.addEventListener('fetch', (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
// App pages: Network First with cache fallback
|
||||
if (pathname === '/app' || pathname === '/' || pathname.startsWith('/app')) {
|
||||
// App pages / navigation: Network First with cache fallback
|
||||
if (event.request.mode === 'navigate' || pathname === '/app' || pathname === '/' || pathname.startsWith('/app')) {
|
||||
event.respondWith(
|
||||
fetch(event.request).then((response) => {
|
||||
if (response.ok) {
|
||||
@@ -81,7 +154,7 @@ self.addEventListener('fetch', (event) => {
|
||||
|
||||
// Clean old caches on activation
|
||||
self.addEventListener('activate', (event) => {
|
||||
const currentCaches = [TILE_CACHE, STATIC_CACHE, APP_CACHE]
|
||||
const currentCaches = [TILE_CACHE, STATIC_CACHE, APP_CACHE, API_CACHE]
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
@@ -92,3 +165,23 @@ self.addEventListener('activate', (event) => {
|
||||
).then(() => self.clients.claim())
|
||||
)
|
||||
})
|
||||
|
||||
// Listen for sync events (Background Sync for queued saves)
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'sync-saves') {
|
||||
event.waitUntil(syncQueuedSaves())
|
||||
}
|
||||
})
|
||||
|
||||
// Process queued saves from IndexedDB/localStorage
|
||||
async function syncQueuedSaves() {
|
||||
try {
|
||||
const clients = await self.clients.matchAll()
|
||||
clients.forEach(client => client.postMessage({ type: 'SYNC_START' }))
|
||||
|
||||
// Read queue from a BroadcastChannel or let the main thread handle it
|
||||
clients.forEach(client => client.postMessage({ type: 'FLUSH_SYNC_QUEUE' }))
|
||||
} catch (e) {
|
||||
console.error('[SW] Sync error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
35
src/app/admin/error.tsx
Normal file
35
src/app/admin/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
|
||||
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">Fehler im Admin-Bereich</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/app">
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Zur App
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || !isAdmin(user.role)) {
|
||||
if (!user || (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN')) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -55,27 +55,25 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Generate safe filename
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'png'
|
||||
const isTenantAdmin = user.role === 'TENANT_ADMIN'
|
||||
const prefix = isTenantAdmin ? `tenant-${user.tenantId}/icons` : 'icons'
|
||||
const safeFileName = `${uuidv4()}.${ext}`
|
||||
const fileKey = `icons/${safeFileName}`
|
||||
const fileKey = `${prefix}/${safeFileName}`
|
||||
|
||||
// Upload to MinIO
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await uploadFile(fileKey, buffer, file.type)
|
||||
|
||||
// TENANT_ADMIN: icons get tenantId. SERVER_ADMIN: global icons (tenantId=null)
|
||||
const tenantId = user.role === 'SERVER_ADMIN' ? null : user.tenantId || null
|
||||
|
||||
// Create database entry
|
||||
// Save to DB
|
||||
const icon = await (prisma as any).iconAsset.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
fileKey,
|
||||
mimeType: file.type,
|
||||
categoryId,
|
||||
iconType: iconType as any,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
tenantId,
|
||||
fileKey,
|
||||
mimeType: file.type,
|
||||
isSystem: !isTenantAdmin, // true für Server Admin, false für Tenant Admin
|
||||
tenantId: isTenantAdmin ? user.tenantId : null,
|
||||
ownerId: user.id,
|
||||
},
|
||||
include: {
|
||||
|
||||
39
src/app/api/admin/projects/route.ts
Normal file
39
src/app/api/admin/projects/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId')
|
||||
|
||||
const where: any = {}
|
||||
if (tenantId) where.tenantId = tenantId
|
||||
|
||||
const projects = await (prisma as any).project.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
owner: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
tenant: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
_count: {
|
||||
select: { features: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ projects })
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin projects:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { rateLimit, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
const changePwLimiter = rateLimit({ id: 'change-pw', max: 5, windowSeconds: 60 * 15 })
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const rl = changePwLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
@@ -14,8 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 8 Zeichen lang sein' }, { status: 400 })
|
||||
}
|
||||
|
||||
const dbUser = await (prisma as any).user.findUnique({
|
||||
|
||||
70
src/app/api/auth/delete-account/route.ts
Normal file
70
src/app/api/auth/delete-account/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { cookies } from 'next/headers'
|
||||
import { deleteAccountLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
// POST: User deletes their own account
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const rl = deleteAccountLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const { password } = await req.json()
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: 'Passwort erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const user = await (prisma as any).user.findUnique({
|
||||
where: { id: session.id },
|
||||
select: { id: true, password: true, role: true },
|
||||
})
|
||||
if (!user) return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 404 })
|
||||
|
||||
const validPw = await bcrypt.compare(password, user.password)
|
||||
if (!validPw) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort' }, { status: 403 })
|
||||
}
|
||||
|
||||
// If user is the only TENANT_ADMIN, they must delete the org first or transfer ownership
|
||||
if (session.tenantId && session.role === 'TENANT_ADMIN') {
|
||||
const adminCount = await (prisma as any).tenantMembership.count({
|
||||
where: { tenantId: session.tenantId, role: 'TENANT_ADMIN' },
|
||||
})
|
||||
if (adminCount <= 1) {
|
||||
return NextResponse.json({
|
||||
error: 'Sie sind der einzige Administrator. Bitte löschen Sie die Organisation unter Einstellungen oder übertragen Sie die Admin-Rolle.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Account Delete] User ${session.id} (${session.email}) deleting own account`)
|
||||
|
||||
// Clean up user data
|
||||
try { await (prisma as any).upgradeRequest.deleteMany({ where: { requestedById: session.id } }) } catch {}
|
||||
try { await (prisma as any).iconAsset.updateMany({ where: { ownerId: session.id }, data: { ownerId: null } }) } catch {}
|
||||
try { await (prisma as any).project.updateMany({ where: { ownerId: session.id }, data: { ownerId: null } }) } catch {}
|
||||
|
||||
// Remove memberships
|
||||
await (prisma as any).tenantMembership.deleteMany({ where: { userId: session.id } })
|
||||
|
||||
// Delete user
|
||||
await (prisma as any).user.delete({ where: { id: session.id } })
|
||||
|
||||
// Clear auth cookie
|
||||
;(await cookies()).delete('auth-token')
|
||||
|
||||
console.log(`[Account Delete] User ${session.email} deleted successfully`)
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Konto wurde gelöscht' })
|
||||
} catch (error: any) {
|
||||
console.error('[Account Delete] Error:', error?.message || error)
|
||||
return NextResponse.json({ error: 'Löschung fehlgeschlagen' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { sendEmail, getSmtpConfig } from '@/lib/email'
|
||||
import { forgotPasswordLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const rl = forgotPasswordLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const { email } = await req.json()
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
|
||||
|
||||
@@ -3,9 +3,14 @@ import { cookies } from 'next/headers'
|
||||
import { login, createToken } from '@/lib/auth'
|
||||
import { loginSchema } from '@/lib/validations'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(request)
|
||||
const rl = loginLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const validated = loginSchema.safeParse(body)
|
||||
@@ -17,11 +22,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { email, password } = validated.data
|
||||
const rememberMe = body.rememberMe === true
|
||||
const result = await login(email, password)
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
const remaining = rl.remaining
|
||||
const warningText = remaining <= 3 && remaining > 0
|
||||
? ` (Noch ${remaining} Versuch${remaining === 1 ? '' : 'e'})`
|
||||
: ''
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Login fehlgeschlagen' },
|
||||
{ error: (result.error || 'Login fehlgeschlagen') + warningText, remaining },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
@@ -34,13 +44,13 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
} catch {}
|
||||
|
||||
const token = await createToken(result.user)
|
||||
const token = await createToken(result.user, rememberMe)
|
||||
|
||||
;(await cookies()).set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
maxAge: rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24, // 30 days or 24 hours
|
||||
path: '/',
|
||||
})
|
||||
|
||||
|
||||
@@ -4,16 +4,21 @@ import { hashPassword } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
const registerSchema = z.object({
|
||||
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
||||
name: z.string().min(2, 'Name zu kurz').max(200),
|
||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen haben'),
|
||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const rl = registerLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const body = await req.json()
|
||||
const data = registerSchema.parse(body)
|
||||
|
||||
|
||||
75
src/app/api/auth/resend-verification/route.ts
Normal file
75
src/app/api/auth/resend-verification/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { sendEmail } from '@/lib/email'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { resendVerificationLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const rl = resendVerificationLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const { email } = await req.json()
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: 'E-Mail-Adresse erforderlich.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await (prisma as any).user.findUnique({
|
||||
where: { email },
|
||||
include: { memberships: { include: { tenant: true } } },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal whether user exists
|
||||
return NextResponse.json({ success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine neue Bestätigungsmail gesendet.' })
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return NextResponse.json({ success: true, message: 'Ihre E-Mail-Adresse ist bereits bestätigt. Sie können sich anmelden.' })
|
||||
}
|
||||
|
||||
// Generate new verification token
|
||||
const verificationToken = randomBytes(32).toString('hex')
|
||||
await (prisma as any).user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerificationToken: verificationToken },
|
||||
})
|
||||
|
||||
// Build verification URL
|
||||
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
|
||||
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = `https://${baseUrl}`
|
||||
}
|
||||
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${verificationToken}`
|
||||
|
||||
const orgName = user.memberships?.[0]?.tenant?.name || 'Lageplan'
|
||||
|
||||
await sendEmail(
|
||||
user.email,
|
||||
'E-Mail-Adresse bestätigen — Lageplan',
|
||||
`<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="background:#dc2626;color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
|
||||
<h1 style="margin:0;font-size:22px;">E-Mail bestätigen</h1>
|
||||
</div>
|
||||
<div style="border:1px solid #e5e7eb;border-top:none;padding:24px;border-radius:0 0 12px 12px;">
|
||||
<p>Hallo <strong>${user.name}</strong>,</p>
|
||||
<p>Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto für <strong>${orgName}</strong> zu aktivieren.</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="${verifyUrl}" style="background:#dc2626;color:white;padding:12px 32px;text-decoration:none;border-radius:8px;font-weight:600;display:inline-block;">
|
||||
E-Mail bestätigen
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#666;font-size:13px;">Falls der Button nicht funktioniert, kopieren Sie diesen Link:<br/>
|
||||
<a href="${verifyUrl}" style="word-break:break-all;">${verifyUrl}</a></p>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Bestätigungsmail wurde erneut gesendet. Bitte prüfen Sie Ihren Posteingang.' })
|
||||
} catch (error) {
|
||||
console.error('Resend verification error:', error)
|
||||
return NextResponse.json({ error: 'Fehler beim Senden der Bestätigungsmail.' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { hashPassword } from '@/lib/auth'
|
||||
import { resetPasswordLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const rl = resetPasswordLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const { token, password } = await req.json()
|
||||
if (!token || !password) {
|
||||
return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await (prisma as any).user.findFirst({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { sendEmail, getSmtpConfig } from '@/lib/email'
|
||||
import { z } from 'zod'
|
||||
import { contactLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -23,6 +24,10 @@ async function getContactEmail(): Promise<string> {
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const rl = contactLimiter.check(ip)
|
||||
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||
|
||||
const body = await req.json()
|
||||
const data = contactSchema.parse(body)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ export async function POST(req: NextRequest) {
|
||||
const origin = req.headers.get('origin') || req.nextUrl.origin
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card', 'twint'],
|
||||
mode: 'payment',
|
||||
line_items: [
|
||||
{
|
||||
|
||||
@@ -6,20 +6,6 @@ export async function GET() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
|
||||
// Build icon filter: global icons (tenantId=null) + tenant-specific icons
|
||||
const iconFilter: any = { isActive: true }
|
||||
if (user?.tenantId) {
|
||||
iconFilter.OR = [
|
||||
{ tenantId: null },
|
||||
{ tenantId: user.tenantId },
|
||||
]
|
||||
delete iconFilter.isActive
|
||||
iconFilter.AND = [{ isActive: true }]
|
||||
} else {
|
||||
// Server admin or no tenant: show all global icons
|
||||
iconFilter.tenantId = null
|
||||
}
|
||||
|
||||
// Filter categories: global (tenantId=null) + tenant-specific
|
||||
const categoryWhere: any = user?.tenantId
|
||||
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
||||
@@ -32,13 +18,13 @@ export async function GET() {
|
||||
icons: {
|
||||
where: user?.tenantId
|
||||
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
|
||||
: { isActive: true },
|
||||
: { isActive: true, tenantId: null },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get tenant's hidden icon IDs
|
||||
// Get tenant's hidden icon IDs (legacy)
|
||||
let hiddenIconIds: string[] = []
|
||||
if (user?.tenantId) {
|
||||
const tenant = await (prisma as any).tenant.findUnique({
|
||||
@@ -58,7 +44,26 @@ export async function GET() {
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ categories: categoriesWithUrls })
|
||||
// Get tenant's custom symbol collection (with custom names)
|
||||
let mySymbols: any[] = []
|
||||
if (user?.tenantId) {
|
||||
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||
where: { tenantId: user.tenantId },
|
||||
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
mySymbols = tenantSymbols.map((ts: any) => ({
|
||||
id: ts.icon.id,
|
||||
tenantSymbolId: ts.id,
|
||||
name: ts.customName || ts.icon.name,
|
||||
customName: ts.customName,
|
||||
mimeType: ts.icon.mimeType,
|
||||
iconType: ts.icon.iconType,
|
||||
url: `/api/icons/${ts.icon.id}/image`,
|
||||
}))
|
||||
}
|
||||
|
||||
return NextResponse.json({ categories: categoriesWithUrls, mySymbols })
|
||||
} catch (error) {
|
||||
console.error('Error fetching icons:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
|
||||
@@ -6,21 +6,22 @@ import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
}
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const features = await (prisma as any).feature.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
@@ -33,9 +34,10 @@ export async function GET(
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
@@ -45,7 +47,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
@@ -66,7 +68,7 @@ export async function POST(
|
||||
|
||||
const feature = await (prisma as any).feature.create({
|
||||
data: {
|
||||
projectId: params.id,
|
||||
projectId: id,
|
||||
type: validated.data.type,
|
||||
geometry: validated.data.geometry,
|
||||
properties: validated.data.properties || {},
|
||||
@@ -82,9 +84,10 @@ export async function POST(
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
@@ -94,11 +97,11 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) {
|
||||
const exists = await (prisma as any).project.findUnique({ where: { id: params.id }, select: { id: true, tenantId: true, ownerId: true } })
|
||||
const exists = await (prisma as any).project.findUnique({ where: { id }, select: { id: true, tenantId: true, ownerId: true } })
|
||||
if (!exists) {
|
||||
console.warn(`[Features PUT] Project ${params.id} not in DB`)
|
||||
console.warn(`[Features PUT] Project ${id} not in DB`)
|
||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
console.warn(`[Features PUT] Access denied: user=${user.id} tenant=${user.tenantId}, project owner=${exists.ownerId} tenant=${exists.tenantId}`)
|
||||
@@ -110,16 +113,28 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { features } = body as { features: Array<{ id?: string; type: string; geometry: object; properties?: object }> }
|
||||
const { features, mapCenter, mapZoom } = body as {
|
||||
features: Array<{ id?: string; type: string; geometry: object; properties?: object }>
|
||||
mapCenter?: { lng: number; lat: number }
|
||||
mapZoom?: number
|
||||
}
|
||||
|
||||
// Persist map viewport alongside features (if provided)
|
||||
if (mapCenter && mapZoom !== undefined) {
|
||||
await (prisma as any).project.update({
|
||||
where: { id },
|
||||
data: { mapCenter, mapZoom },
|
||||
})
|
||||
}
|
||||
|
||||
await (prisma as any).feature.deleteMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
})
|
||||
|
||||
if (features && features.length > 0) {
|
||||
await (prisma as any).feature.createMany({
|
||||
data: features.map((f: any) => ({
|
||||
projectId: params.id,
|
||||
projectId: id,
|
||||
type: f.type,
|
||||
geometry: f.geometry,
|
||||
properties: f.properties || {},
|
||||
@@ -128,7 +143,7 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const updatedFeatures = await (prisma as any).feature.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ features: updatedFeatures })
|
||||
|
||||
@@ -4,18 +4,19 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
// PUT: Toggle confirmed/ok on a check item
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; itemId: string }> }) {
|
||||
try {
|
||||
const { id, itemId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
// Verify item belongs to this project
|
||||
const existing = await (prisma as any).journalCheckItem.findFirst({
|
||||
where: { id: params.itemId, projectId: params.id },
|
||||
where: { id: itemId, projectId: id },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
|
||||
|
||||
@@ -32,7 +33,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string;
|
||||
}
|
||||
|
||||
const item = await (prisma as any).journalCheckItem.update({
|
||||
where: { id: params.itemId },
|
||||
where: { id: itemId },
|
||||
data,
|
||||
})
|
||||
return NextResponse.json(item)
|
||||
@@ -43,22 +44,23 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string;
|
||||
}
|
||||
|
||||
// DELETE
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string; itemId: string } }) {
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string; itemId: string }> }) {
|
||||
try {
|
||||
const { id, itemId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
// Verify item belongs to this project
|
||||
const existing = await (prisma as any).journalCheckItem.findFirst({
|
||||
where: { id: params.itemId, projectId: params.id },
|
||||
where: { id: itemId, projectId: id },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: 'Element nicht gefunden' }, { status: 404 })
|
||||
|
||||
await (prisma as any).journalCheckItem.delete({ where: { id: params.itemId } })
|
||||
await (prisma as any).journalCheckItem.delete({ where: { id: itemId } })
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting check item:', error)
|
||||
|
||||
@@ -4,13 +4,14 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
// POST: Add check item (or initialize from templates)
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const body = await req.json()
|
||||
@@ -18,20 +19,27 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
// If 'initFromTemplates' is true, create check items from templates (only if none exist)
|
||||
if (body.initFromTemplates) {
|
||||
const existing = await (prisma as any).journalCheckItem.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
})
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json(existing)
|
||||
}
|
||||
const templates = await (prisma as any).journalCheckTemplate.findMany({
|
||||
where: { isActive: true },
|
||||
// Prefer tenant-specific templates; fall back to global (tenantId=null) if none exist
|
||||
let templates = await (prisma as any).journalCheckTemplate.findMany({
|
||||
where: { isActive: true, tenantId: user.tenantId || null },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
if (templates.length === 0 && user.tenantId) {
|
||||
templates = await (prisma as any).journalCheckTemplate.findMany({
|
||||
where: { isActive: true, tenantId: null },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}
|
||||
const items = await Promise.all(
|
||||
templates.map((tpl: any, i: number) =>
|
||||
(prisma as any).journalCheckItem.create({
|
||||
data: {
|
||||
projectId: params.id,
|
||||
projectId: id,
|
||||
label: tpl.label,
|
||||
sortOrder: i,
|
||||
},
|
||||
@@ -44,7 +52,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
// Single item creation
|
||||
const item = await (prisma as any).journalCheckItem.create({
|
||||
data: {
|
||||
projectId: params.id,
|
||||
projectId: id,
|
||||
label: body.label || '',
|
||||
sortOrder: body.sortOrder || 0,
|
||||
},
|
||||
|
||||
@@ -4,17 +4,18 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
// PUT: Update a journal entry — only toggle done status allowed directly
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; entryId: string }> }) {
|
||||
try {
|
||||
const { id, entryId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const existing = await (prisma as any).journalEntry.findFirst({
|
||||
where: { id: params.entryId, projectId: params.id },
|
||||
where: { id: entryId, projectId: id },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
|
||||
|
||||
@@ -23,7 +24,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string;
|
||||
// Only done toggle is allowed as direct edit
|
||||
if (body.done !== undefined) {
|
||||
const entry = await (prisma as any).journalEntry.update({
|
||||
where: { id: params.entryId },
|
||||
where: { id: entryId },
|
||||
data: { done: body.done, doneAt: body.done ? new Date() : null },
|
||||
})
|
||||
return NextResponse.json(entry)
|
||||
@@ -38,17 +39,18 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string;
|
||||
|
||||
// POST: Create a correction for a journal entry (replaces DELETE)
|
||||
// Marks the original as corrected (strikethrough) and creates a new correction entry below it
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string; entryId: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string; entryId: string }> }) {
|
||||
try {
|
||||
const { id, entryId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const existing = await (prisma as any).journalEntry.findFirst({
|
||||
where: { id: params.entryId, projectId: params.id },
|
||||
where: { id: entryId, projectId: id },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
|
||||
|
||||
@@ -69,7 +71,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string;
|
||||
|
||||
// Mark original as corrected
|
||||
await (prisma as any).journalEntry.update({
|
||||
where: { id: params.entryId },
|
||||
where: { id: entryId },
|
||||
data: { isCorrected: true },
|
||||
})
|
||||
|
||||
@@ -81,7 +83,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string;
|
||||
who: body.who || existing.who || user.name,
|
||||
sortOrder: existing.sortOrder + 1,
|
||||
correctionOfId: existing.id,
|
||||
projectId: params.id,
|
||||
projectId: id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -4,19 +4,20 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
// POST: Add a new journal entry
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const body = await req.json()
|
||||
const entry = await (prisma as any).journalEntry.create({
|
||||
data: {
|
||||
projectId: params.id,
|
||||
projectId: id,
|
||||
time: body.time ? new Date(body.time) : new Date(),
|
||||
what: body.what || '',
|
||||
who: body.who || null,
|
||||
|
||||
@@ -4,18 +4,19 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
// PUT: Update a pendenz
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; pendenzId: string }> }) {
|
||||
try {
|
||||
const { id, pendenzId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
// Verify pendenz belongs to this project
|
||||
const existing = await (prisma as any).journalPendenz.findFirst({
|
||||
where: { id: params.pendenzId, projectId: params.id },
|
||||
where: { id: pendenzId, projectId: id },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
|
||||
|
||||
@@ -30,7 +31,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string;
|
||||
}
|
||||
|
||||
const item = await (prisma as any).journalPendenz.update({
|
||||
where: { id: params.pendenzId },
|
||||
where: { id: pendenzId },
|
||||
data,
|
||||
})
|
||||
return NextResponse.json(item)
|
||||
@@ -41,22 +42,23 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string;
|
||||
}
|
||||
|
||||
// DELETE
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string; pendenzId: string } }) {
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string; pendenzId: string }> }) {
|
||||
try {
|
||||
const { id, pendenzId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
// Verify pendenz belongs to this project
|
||||
const existing = await (prisma as any).journalPendenz.findFirst({
|
||||
where: { id: params.pendenzId, projectId: params.id },
|
||||
where: { id: pendenzId, projectId: id },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: 'Pendenz nicht gefunden' }, { status: 404 })
|
||||
|
||||
await (prisma as any).journalPendenz.delete({ where: { id: params.pendenzId } })
|
||||
await (prisma as any).journalPendenz.delete({ where: { id: pendenzId } })
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting pendenz:', error)
|
||||
|
||||
@@ -4,19 +4,20 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
// POST: Add a new pendenz
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const body = await req.json()
|
||||
const item = await (prisma as any).journalPendenz.create({
|
||||
data: {
|
||||
projectId: params.id,
|
||||
projectId: id,
|
||||
what: body.what || '',
|
||||
who: body.who || null,
|
||||
whenHow: body.whenHow || null,
|
||||
|
||||
@@ -4,25 +4,26 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
// GET all journal data for a project (entries, check items, pendenzen)
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const [entries, checkItems, pendenzen] = await Promise.all([
|
||||
(prisma as any).journalEntry.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
(prisma as any).journalCheckItem.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
}),
|
||||
(prisma as any).journalPendenz.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -4,12 +4,13 @@ import { getSession } from '@/lib/auth'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
import { sendEmail } from '@/lib/email'
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
// Load tenant logo
|
||||
@@ -32,17 +33,17 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
|
||||
// Load journal data
|
||||
const entries = await (prisma as any).journalEntry.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
orderBy: [{ time: 'asc' }, { sortOrder: 'asc' }],
|
||||
})
|
||||
|
||||
const checkItems = await (prisma as any).journalCheckItem.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const pendenzen = await (prisma as any).journalPendenz.findMany({
|
||||
where: { projectId: params.id },
|
||||
where: { projectId: id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
import { uploadFile, deleteFile, getFileUrl } from '@/lib/minio'
|
||||
|
||||
// POST: Upload a plan image for a project
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const formData = await req.formData()
|
||||
@@ -37,7 +38,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
// Upload to MinIO
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const ext = file.name.split('.').pop() || 'png'
|
||||
const fileKey = `plans/${params.id}/${Date.now()}.${ext}`
|
||||
const fileKey = `plans/${id}/${Date.now()}.${ext}`
|
||||
await uploadFile(fileKey, buffer, file.type)
|
||||
|
||||
// Parse bounds or use default (current map view)
|
||||
@@ -48,7 +49,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
|
||||
// Update project
|
||||
await (prisma as any).project.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: {
|
||||
planImageKey: fileKey,
|
||||
planBounds: bounds,
|
||||
@@ -70,13 +71,14 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
||||
}
|
||||
|
||||
// DELETE: Remove the plan image
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const p = project as any
|
||||
@@ -85,7 +87,7 @@ export async function DELETE(req: NextRequest, { params }: { params: { id: strin
|
||||
}
|
||||
|
||||
await (prisma as any).project.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: { planImageKey: null, planBounds: null },
|
||||
})
|
||||
|
||||
@@ -97,19 +99,20 @@ export async function DELETE(req: NextRequest, { params }: { params: { id: strin
|
||||
}
|
||||
|
||||
// PATCH: Update plan bounds (repositioning)
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(params.id, user)
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const body = await req.json()
|
||||
if (!body.bounds) return NextResponse.json({ error: 'Bounds erforderlich' }, { status: 400 })
|
||||
|
||||
await (prisma as any).project.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: { planBounds: body.bounds },
|
||||
})
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import { getSession } from '@/lib/auth'
|
||||
import { getFileStream } from '@/lib/minio'
|
||||
|
||||
// Serve plan image (authenticated users only)
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const project = await (prisma as any).project.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
select: { planImageKey: true },
|
||||
})
|
||||
|
||||
|
||||
@@ -6,22 +6,23 @@ import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
}
|
||||
|
||||
const projectBase = await getProjectWithTenantCheck(params.id, user)
|
||||
const projectBase = await getProjectWithTenantCheck(id, user)
|
||||
if (!projectBase) {
|
||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Re-fetch with includes
|
||||
const project = await (prisma as any).project.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: {
|
||||
owner: {
|
||||
select: { id: true, name: true, email: true },
|
||||
@@ -39,9 +40,10 @@ export async function GET(
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
@@ -51,7 +53,7 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const existingProject = await getProjectWithTenantCheck(params.id, user)
|
||||
const existingProject = await getProjectWithTenantCheck(id, user)
|
||||
if (!existingProject) {
|
||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
@@ -67,7 +69,7 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
const project = await (prisma as any).project.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: validated.data,
|
||||
})
|
||||
|
||||
@@ -80,15 +82,16 @@ export async function PATCH(
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
}
|
||||
|
||||
const existingProject = await getProjectWithTenantCheck(params.id, user)
|
||||
const existingProject = await getProjectWithTenantCheck(id, user)
|
||||
if (!existingProject) {
|
||||
return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
@@ -99,7 +102,7 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
await (prisma as any).project.delete({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -40,10 +40,11 @@ async function resolveLogoDataUri(rapport: any): Promise<string> {
|
||||
}
|
||||
|
||||
// GET: Generate and serve PDF for a rapport (public, token-based)
|
||||
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
|
||||
try {
|
||||
const { token } = await params
|
||||
const rapport = await (prisma as any).rapport.findUnique({
|
||||
where: { token: params.token },
|
||||
where: { token },
|
||||
include: {
|
||||
tenant: { select: { name: true } },
|
||||
},
|
||||
@@ -68,10 +69,10 @@ export async function GET(req: NextRequest, { params }: { params: { token: strin
|
||||
const { RapportDocument } = await import('@/lib/rapport-pdf')
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(RapportDocument, { data: pdfData })
|
||||
React.createElement(RapportDocument, { data: pdfData }) as any
|
||||
)
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
return new NextResponse(Buffer.from(buffer) as any, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="Rapport-${rapport.reportNumber}.pdf"`,
|
||||
|
||||
@@ -37,10 +37,11 @@ async function resolveLogoForClient(rapport: any): Promise<string> {
|
||||
}
|
||||
|
||||
// GET: Public access to rapport by token (no auth required)
|
||||
export async function GET(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
|
||||
try {
|
||||
const { token } = await params
|
||||
const rapport = await (prisma as any).rapport.findUnique({
|
||||
where: { token: params.token },
|
||||
where: { token },
|
||||
include: {
|
||||
project: { select: { title: true, location: true } },
|
||||
tenant: { select: { name: true } },
|
||||
|
||||
@@ -4,8 +4,9 @@ import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email'
|
||||
|
||||
// POST: Send rapport link via email
|
||||
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
|
||||
try {
|
||||
const { token } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
@@ -13,7 +14,7 @@ export async function POST(req: NextRequest, { params }: { params: { token: stri
|
||||
if (!email) return NextResponse.json({ error: 'E-Mail-Adresse erforderlich' }, { status: 400 })
|
||||
|
||||
const rapport = await (prisma as any).rapport.findUnique({
|
||||
where: { token: params.token },
|
||||
where: { token },
|
||||
include: {
|
||||
tenant: { select: { name: true } },
|
||||
project: { select: { title: true, location: true } },
|
||||
|
||||
@@ -17,9 +17,13 @@ export async function GET(req: NextRequest) {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
address: true,
|
||||
logoUrl: true,
|
||||
plan: true,
|
||||
subscriptionStatus: true,
|
||||
contactEmail: true,
|
||||
privacyAccepted: true,
|
||||
privacyAcceptedAt: true,
|
||||
adminAccessAccepted: true,
|
||||
@@ -39,3 +43,35 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN') return NextResponse.json({ error: 'Nur Admin' }, { status: 403 })
|
||||
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||
|
||||
const body = await req.json()
|
||||
const { name, description, contactEmail, contactPhone, address } = body
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await (prisma as any).tenant.update({
|
||||
where: { id: user.tenantId },
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description || null,
|
||||
contactEmail: contactEmail || null,
|
||||
contactPhone: contactPhone || null,
|
||||
address: address || null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ tenant: updated })
|
||||
} catch (error: any) {
|
||||
console.error('[Tenant Info PATCH] Error:', error?.message)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
77
src/app/api/tenant/logo/route.ts
Normal file
77
src/app/api/tenant/logo/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { uploadFile, deleteFile } from '@/lib/minio'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'TENANT_ADMIN') {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('logo') as File
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Keine Datei hochgeladen' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Ungültiges Dateiformat. Erlaubt: PNG, JPEG, SVG, WebP' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: 'Datei zu gross (max. 2 MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const ext = file.name.split('.').pop() || 'png'
|
||||
const fileKey = `logos/tenant-${user.tenantId}.${ext}`
|
||||
|
||||
await uploadFile(fileKey, buffer, file.type)
|
||||
|
||||
const logoServeUrl = `/api/admin/tenants/${user.tenantId}/logo/serve`
|
||||
await (prisma as any).tenant.update({
|
||||
where: { id: user.tenantId },
|
||||
data: { logoFileKey: fileKey, logoUrl: logoServeUrl },
|
||||
})
|
||||
|
||||
return NextResponse.json({ logoUrl: logoServeUrl })
|
||||
} catch (error) {
|
||||
console.error('Tenant logo upload error:', error)
|
||||
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'TENANT_ADMIN') {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||
}
|
||||
|
||||
const tenant = await (prisma as any).tenant.findUnique({ where: { id: user.tenantId } })
|
||||
if (tenant?.logoFileKey) {
|
||||
try {
|
||||
await deleteFile(tenant.logoFileKey)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await (prisma as any).tenant.update({
|
||||
where: { id: user.tenantId },
|
||||
data: { logoUrl: null, logoFileKey: null },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Tenant logo delete error:', error)
|
||||
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
108
src/app/api/tenant/soma-templates/route.ts
Normal file
108
src/app/api/tenant/soma-templates/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
// GET: List SOMA templates for the current tenant
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const templates = await (prisma as any).journalCheckTemplate.findMany({
|
||||
where: { tenantId: user.tenantId || null },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ templates })
|
||||
} catch (error) {
|
||||
console.error('Error fetching SOMA templates:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create a new SOMA template for the current tenant
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { label, sortOrder } = await req.json()
|
||||
if (!label?.trim()) {
|
||||
return NextResponse.json({ error: 'Label ist erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
const template = await (prisma as any).journalCheckTemplate.create({
|
||||
data: {
|
||||
label: label.trim(),
|
||||
sortOrder: sortOrder ?? 0,
|
||||
tenantId: user.tenantId || null,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ template }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating SOMA template:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Update multiple templates (bulk reorder/toggle)
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { updates } = await req.json()
|
||||
if (!Array.isArray(updates)) {
|
||||
return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
updates.map((u: { id: string; label?: string; sortOrder?: number; isActive?: boolean }) =>
|
||||
(prisma as any).journalCheckTemplate.update({
|
||||
where: { id: u.id },
|
||||
data: {
|
||||
...(u.label !== undefined && { label: u.label }),
|
||||
...(u.sortOrder !== undefined && { sortOrder: u.sortOrder }),
|
||||
...(u.isActive !== undefined && { isActive: u.isActive }),
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating SOMA templates:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete a SOMA template
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await req.json()
|
||||
if (!id) return NextResponse.json({ error: 'ID erforderlich' }, { status: 400 })
|
||||
|
||||
await (prisma as any).journalCheckTemplate.delete({ where: { id } })
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting SOMA template:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
152
src/app/api/tenant/symbols/route.ts
Normal file
152
src/app/api/tenant/symbols/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
async function getTenantId() {
|
||||
const user = await getSession()
|
||||
if (!user) return { error: 'Nicht autorisiert', status: 401 }
|
||||
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
|
||||
return { error: 'Keine Berechtigung', status: 403 }
|
||||
}
|
||||
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
|
||||
return { tenantId: user.tenantId }
|
||||
}
|
||||
|
||||
// GET: Returns library (all system icons) + tenant's own symbol collection
|
||||
export async function GET() {
|
||||
try {
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
// All system icons grouped by category (the library)
|
||||
const icons = await (prisma as any).iconAsset.findMany({
|
||||
where: { isActive: true },
|
||||
include: { category: { select: { id: true, name: true } } },
|
||||
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
|
||||
})
|
||||
|
||||
const library = icons.map((icon: any) => ({
|
||||
id: icon.id,
|
||||
name: icon.name,
|
||||
mimeType: icon.mimeType,
|
||||
iconType: icon.iconType,
|
||||
categoryId: icon.categoryId,
|
||||
categoryName: icon.category?.name || 'Ohne Kategorie',
|
||||
}))
|
||||
|
||||
// Tenant's own symbol collection
|
||||
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
|
||||
where: { tenantId },
|
||||
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const mySymbols = tenantSymbols.map((ts: any) => ({
|
||||
id: ts.id,
|
||||
iconId: ts.iconId,
|
||||
name: ts.customName || ts.icon.name,
|
||||
customName: ts.customName,
|
||||
baseName: ts.icon.name,
|
||||
mimeType: ts.icon.mimeType,
|
||||
iconType: ts.icon.iconType,
|
||||
categoryName: ts.icon.category?.name || 'Ohne Kategorie',
|
||||
sortOrder: ts.sortOrder,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ library, mySymbols })
|
||||
} catch (error) {
|
||||
console.error('Error fetching tenant symbols:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Add a symbol from the library to "my symbols"
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
const { iconId, customName } = await req.json()
|
||||
if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
|
||||
|
||||
// Get max sortOrder for this tenant
|
||||
const maxSort = await (prisma as any).tenantSymbol.aggregate({
|
||||
where: { tenantId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
|
||||
const symbol = await (prisma as any).tenantSymbol.create({
|
||||
data: {
|
||||
tenantId,
|
||||
iconId,
|
||||
customName: customName || null,
|
||||
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
|
||||
},
|
||||
include: { icon: { select: { name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: symbol.id,
|
||||
iconId: symbol.iconId,
|
||||
name: symbol.customName || symbol.icon.name,
|
||||
customName: symbol.customName,
|
||||
baseName: symbol.icon.name,
|
||||
mimeType: symbol.icon.mimeType,
|
||||
iconType: symbol.icon.iconType,
|
||||
categoryName: symbol.icon.category?.name || 'Ohne Kategorie',
|
||||
sortOrder: symbol.sortOrder,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error adding tenant symbol:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Rename a symbol or update sortOrder
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
const { id, customName, sortOrder } = await req.json()
|
||||
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||
|
||||
const data: any = {}
|
||||
if (customName !== undefined) data.customName = customName || null
|
||||
if (sortOrder !== undefined) data.sortOrder = sortOrder
|
||||
|
||||
await (prisma as any).tenantSymbol.updateMany({
|
||||
where: { id, tenantId },
|
||||
data,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating tenant symbol:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Remove a symbol from "my symbols"
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const auth = await getTenantId()
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
const { tenantId } = auth
|
||||
|
||||
const { id } = await req.json()
|
||||
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
|
||||
|
||||
await (prisma as any).tenantSymbol.deleteMany({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting tenant symbol:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import { getSession, isAdmin } from '@/lib/auth'
|
||||
// GET: Fetch journal suggestions for a tenant (global + tenant dictionary merged)
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { tenantId: string } }
|
||||
{ params }: { params: Promise<{ tenantId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { tenantId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
@@ -18,11 +19,11 @@ export async function GET(
|
||||
select: { word: true },
|
||||
}).catch(() => []),
|
||||
(prisma as any).dictionaryEntry.findMany({
|
||||
where: { scope: 'TENANT', tenantId: params.tenantId },
|
||||
where: { scope: 'TENANT', tenantId },
|
||||
select: { word: true },
|
||||
}).catch(() => []),
|
||||
(prisma as any).tenant.findUnique({
|
||||
where: { id: params.tenantId },
|
||||
where: { id: tenantId },
|
||||
select: { journalSuggestions: true },
|
||||
}),
|
||||
])
|
||||
@@ -46,16 +47,17 @@ export async function GET(
|
||||
// PUT: Replace all journal suggestions for a tenant (admin only)
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { tenantId: string } }
|
||||
{ params }: { params: Promise<{ tenantId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { tenantId } = await params
|
||||
const user = await getSession()
|
||||
if (!user || !isAdmin(user.role)) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||
}
|
||||
|
||||
// TENANT_ADMIN can only edit their own tenant
|
||||
if (user.role !== 'SERVER_ADMIN' && user.tenantId !== params.tenantId) {
|
||||
if (user.role !== 'SERVER_ADMIN' && user.tenantId !== tenantId) {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ export async function PUT(
|
||||
: []
|
||||
|
||||
await (prisma as any).tenant.update({
|
||||
where: { id: params.tenantId },
|
||||
where: { id: tenantId },
|
||||
data: { journalSuggestions: suggestions },
|
||||
})
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import { prisma } from '@/lib/db'
|
||||
// Public endpoint: get tenant info by slug (logo, name)
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { slug: string } }
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const tenant = await (prisma as any).tenant.findUnique({
|
||||
where: { slug: params.slug },
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
|
||||
@@ -15,9 +15,10 @@ const processSchema = z.object({
|
||||
// PATCH: Approve or reject an upgrade request (SERVER_ADMIN only)
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const user = await getSession()
|
||||
if (!user || !isServerAdmin(user.role)) {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||
@@ -31,7 +32,7 @@ export async function PATCH(
|
||||
|
||||
// Get the request
|
||||
const upgradeReq = await (prisma as any).upgradeRequest.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true, plan: true, contactEmail: true } },
|
||||
requestedBy: { select: { name: true, email: true } },
|
||||
@@ -71,7 +72,7 @@ export async function PATCH(
|
||||
|
||||
// Update request status
|
||||
await (prisma as any).upgradeRequest.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
adminNote: validated.data.adminNote || null,
|
||||
@@ -127,7 +128,7 @@ export async function PATCH(
|
||||
} else {
|
||||
// Reject
|
||||
await (prisma as any).upgradeRequest.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
adminNote: validated.data.adminNote || null,
|
||||
@@ -170,7 +171,7 @@ export async function PATCH(
|
||||
|
||||
// Return updated request
|
||||
const updated = await (prisma as any).upgradeRequest.findUnique({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: {
|
||||
tenant: { select: { name: true, slug: true, plan: true, subscriptionStatus: true } },
|
||||
requestedBy: { select: { name: true, email: true } },
|
||||
|
||||
35
src/app/app/error.tsx
Normal file
35
src/app/app/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
|
||||
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">Fehler in der Krokier-App</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/">
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Startseite
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1068
src/app/app/page.tsx
1068
src/app/app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -4,25 +4,26 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
/* Light Mode — optimiert für Einsatz-Kontext (WCAG 2.1 AA) */
|
||||
--background: 210 33% 99%; /* #fafbfc — leicht abgesetzt, kein reines Weiss */
|
||||
--foreground: 217.2 32.6% 17.5%; /* Slate-800 #1e293b — Primärtext, 12.6:1 Kontrast */
|
||||
--card: 0 0% 100%; /* Weisse Karten/Panels */
|
||||
--card-foreground: 217.2 32.6% 17.5%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--popover-foreground: 217.2 32.6% 17.5%;
|
||||
--primary: 222.2 47.4% 11.2%; /* Slate-900 — Buttons, aktive Elemente */
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--secondary: 210 40% 96.1%; /* Slate-100 */
|
||||
--secondary-foreground: 217.2 32.6% 17.5%;
|
||||
--muted: 210 40% 96.1%; /* Slate-100 — Hover, dezente Flächen */
|
||||
--muted-foreground: 215.3 19.4% 34.5%; /* Slate-600 #475569 — Sekundärtext, 7.1:1 Kontrast */
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--accent-foreground: 217.2 32.6% 17.5%;
|
||||
--destructive: 0 72.2% 50.6%; /* Feuerwehr-Rot #dc2626 */
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214.3 31.8% 91.4%; /* Slate-200 #e2e8f0 */
|
||||
--input: 212.7 26.8% 83.9%; /* Slate-300 #cbd5e1 — stärkere Input-Borders */
|
||||
--ring: 217.2 91.2% 59.8%; /* Blau #3b82f6 — Focus-Ring */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,9 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { Barlow } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { AuthProvider } from '@/components/providers/auth-provider'
|
||||
import { ServiceWorkerRegister } from '@/components/providers/sw-register'
|
||||
import { CookieConsent } from '@/components/ui/cookie-consent'
|
||||
|
||||
const inter = Inter({
|
||||
const barlow = Barlow({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
@@ -105,7 +105,7 @@ export default function RootLayout({
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#dc2626" />
|
||||
</head>
|
||||
<body className={`${inter.className} antialiased`} style={{ fontFeatureSettings: '"tnum", "cv01"' }}>
|
||||
<body className={`${barlow.className} antialiased`} style={{ fontFeatureSettings: '"tnum"' }}>
|
||||
<AuthProvider>
|
||||
<ServiceWorkerRegister />
|
||||
{children}
|
||||
|
||||
@@ -22,7 +22,10 @@ export default function LoginPage() {
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [rememberMe, setRememberMe] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [resendLoading, setResendLoading] = useState(false)
|
||||
const [resendSuccess, setResendSuccess] = useState(false)
|
||||
const [tenantLogo, setTenantLogo] = useState<string | null>(null)
|
||||
const [tenantName, setTenantName] = useState<string | null>(null)
|
||||
const { login } = useAuth()
|
||||
@@ -53,7 +56,7 @@ function LoginForm() {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const result = await login(email, password)
|
||||
const result = await login(email, password, rememberMe)
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
@@ -110,7 +113,32 @@ function LoginForm() {
|
||||
)}
|
||||
{errorParam === 'invalid-token' && (
|
||||
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg p-3 mb-4 text-sm text-red-700 dark:text-red-400 text-center">
|
||||
Ungültiger oder abgelaufener Bestätigungslink.
|
||||
<p>Ungültiger oder abgelaufener Bestätigungslink.</p>
|
||||
<p className="mt-1 text-xs">Geben Sie Ihre E-Mail ein und klicken Sie unten, um einen neuen Link zu erhalten.</p>
|
||||
{resendSuccess ? (
|
||||
<p className="mt-2 text-green-600 dark:text-green-400 font-medium">Neue Bestätigungsmail gesendet!</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={resendLoading || !email}
|
||||
onClick={async () => {
|
||||
setResendLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (res.ok) setResendSuccess(true)
|
||||
else toast({ title: 'Fehler', description: 'Konnte Bestätigungsmail nicht senden.', variant: 'destructive' })
|
||||
} catch { toast({ title: 'Fehler', description: 'Verbindungsfehler.', variant: 'destructive' }) }
|
||||
setResendLoading(false)
|
||||
}}
|
||||
className="mt-2 text-xs font-medium text-red-600 dark:text-red-400 underline hover:no-underline disabled:opacity-50"
|
||||
>
|
||||
{resendLoading ? 'Wird gesendet...' : 'Bestätigungsmail erneut senden'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -146,6 +174,16 @@ function LoginForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Angemeldet bleiben</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-red-600 hover:bg-red-700"
|
||||
|
||||
@@ -526,10 +526,10 @@ function SupportSection() {
|
||||
{/* Tier preview */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
||||
{[
|
||||
{ emoji: '\u2615', label: 'Kaffee', amount: 'CHF 5' },
|
||||
{ emoji: '\uD83C\uDF55', label: 'Pizza', amount: 'CHF 10' },
|
||||
{ emoji: '\uD83D\uDDA5\uFE0F', label: 'Server', amount: 'CHF 25' },
|
||||
{ emoji: '\uD83D\uDDA5\uFE0F', label: 'Server', amount: 'CHF 20' },
|
||||
{ emoji: '\uD83D\uDE80', label: 'Feature', amount: 'CHF 50' },
|
||||
{ emoji: '\u2764\uFE0F', label: 'Freibetrag', amount: 'Frei' },
|
||||
].map(tier => (
|
||||
<div key={tier.label} className="rounded-xl p-4 border border-gray-100 bg-gray-50">
|
||||
<span className="text-2xl block mb-1">{tier.emoji}</span>
|
||||
@@ -540,7 +540,7 @@ function SupportSection() {
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 mb-6">
|
||||
Wähle einen Betrag auf unserer Spendenseite — Zahlung sicher via Stripe (Kreditkarte, Twint).
|
||||
Wähle einen Betrag auf unserer Spendenseite — Zahlung sicher via Stripe (Kreditkarte, Twint, Apple Pay, Google Pay).
|
||||
</p>
|
||||
|
||||
<Link href="/spenden">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { Loader2, FileText, Download, Printer, MapPin, Send, CheckCircle, XCircle } from 'lucide-react'
|
||||
|
||||
interface RapportViewData {
|
||||
@@ -13,7 +13,8 @@ interface RapportViewData {
|
||||
createdBy: { name: string } | null
|
||||
}
|
||||
|
||||
export default function RapportViewerPage({ params }: { params: { token: string } }) {
|
||||
export default function RapportViewerPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params)
|
||||
const [rapport, setRapport] = useState<RapportViewData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -25,7 +26,7 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/rapports/${params.token}`)
|
||||
const res = await fetch(`/api/rapports/${token}`)
|
||||
if (res.ok) {
|
||||
setRapport(await res.json())
|
||||
} else {
|
||||
@@ -38,7 +39,7 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [params.token])
|
||||
}, [token])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -64,14 +65,14 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
}
|
||||
|
||||
const d = rapport.data
|
||||
const pdfUrl = `/api/rapports/${params.token}/pdf`
|
||||
const pdfUrl = `/api/rapports/${token}/pdf`
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
{/* Action bar */}
|
||||
<div className="max-w-[210mm] mx-auto mb-4 flex justify-between items-center px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-red-500" />
|
||||
<img src="/logo.svg" alt="Lageplan" className="w-5 h-5 object-contain" />
|
||||
<span className="font-semibold text-sm">Lageplan — Einsatzrapport</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -119,7 +120,7 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
setEmailSending(true)
|
||||
setEmailStatus(null)
|
||||
try {
|
||||
const res = await fetch(`/api/rapports/${params.token}/send`, {
|
||||
const res = await fetch(`/api/rapports/${token}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailTo }),
|
||||
@@ -167,8 +168,7 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
<Field label="Alarmzeit" value={d.alarmzeit} mono />
|
||||
<Field label="Priorität" value={d.prioritaet} last />
|
||||
<Field label="Einsatzort / Adresse" value={d.einsatzort} span={2} />
|
||||
<Field label="Koordinaten" value={d.koordinaten} mono />
|
||||
<Field label="Objekt / Gebäude" value={d.objekt} last />
|
||||
<Field label="Objekt / Gebäude" value={d.objekt} span={2} last />
|
||||
<Field label="Alarmierungsart" value={d.alarmierungsart} span={2} />
|
||||
<Field label="Stichwort / Meldebild" value={d.stichwort} span={2} last />
|
||||
</div>
|
||||
@@ -176,15 +176,9 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
|
||||
{/* 2. Zeitverlauf */}
|
||||
<Section num="2" title="Zeitverlauf">
|
||||
<div className="grid grid-cols-4 border rounded">
|
||||
<div className="grid grid-cols-2 border rounded">
|
||||
<Field label="Alarmierung" value={d.zeitAlarm} mono highlight />
|
||||
<Field label="Ausrücken" value={d.zeitAusruecken} mono highlight />
|
||||
<Field label="Eintreffen" value={d.zeitEintreffen} mono highlight />
|
||||
<Field label="Einsatzbereit" value={d.zeitBereit} mono highlight last />
|
||||
<Field label="Feuer unter Kontrolle" value={d.zeitKontrolle} mono highlight />
|
||||
<Field label="Feuer aus" value={d.zeitAus} mono highlight />
|
||||
<Field label="Einrücken" value={d.zeitEinruecken} mono highlight />
|
||||
<Field label="Einsatzende" value={d.zeitEnde} mono highlight last />
|
||||
<Field label="Eintreffen" value={d.zeitEintreffen} mono highlight last />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -216,9 +210,59 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 5. Eingesetzte Mittel */}
|
||||
{/* 5. SOMA Checkliste */}
|
||||
{Array.isArray(d.somaItems) && d.somaItems.length > 0 && (
|
||||
<Section num="5" title="SOMA Checkliste">
|
||||
<div className="border rounded">
|
||||
<div className="grid grid-cols-[24px_24px_1fr_60px] gap-0 text-[7pt] font-semibold uppercase tracking-wider bg-gray-900 text-white p-1.5">
|
||||
<span className="text-center">Best.</span>
|
||||
<span className="text-center">OK</span>
|
||||
<span>Punkt</span>
|
||||
<span className="text-right">Zeit</span>
|
||||
</div>
|
||||
{d.somaItems.map((s: any, i: number) => (
|
||||
<div key={i} className={`grid grid-cols-[24px_24px_1fr_60px] gap-0 p-1.5 border-b border-gray-100 text-[9pt] ${i % 2 === 1 ? 'bg-gray-50' : ''}`}>
|
||||
<span className="text-center font-bold">{s.confirmed ? '✓' : '—'}</span>
|
||||
<span className="text-center font-bold">{s.ok ? '✓' : '—'}</span>
|
||||
<span className="font-medium">{s.label}</span>
|
||||
<span className="text-right text-[8pt] text-gray-500 font-mono">{s.confirmedAt || ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 6. Pendenzen */}
|
||||
{Array.isArray(d.pendenzenItems) && d.pendenzenItems.length > 0 && (
|
||||
<Section num="6" title="Pendenzen">
|
||||
<table className="w-full border-collapse border rounded text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-white">
|
||||
<th className="p-1.5 text-center font-semibold uppercase tracking-wider text-[7pt] w-8">✓</th>
|
||||
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Aufgabe</th>
|
||||
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-24">Wer</th>
|
||||
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-32">Wann / Wie</th>
|
||||
<th className="p-1.5 text-right font-semibold uppercase tracking-wider text-[7pt] w-16">Erledigt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.pendenzenItems.map((p: any, i: number) => (
|
||||
<tr key={i} className={`${i % 2 === 1 ? 'bg-gray-50' : ''} ${p.done ? 'text-gray-400' : ''}`}>
|
||||
<td className="p-1.5 border-b border-gray-100 text-center font-bold">{p.done ? '✓' : '○'}</td>
|
||||
<td className={`p-1.5 border-b border-gray-100 ${p.done ? 'line-through' : ''}`}>{p.what}</td>
|
||||
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.who || '—'}</td>
|
||||
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.whenHow || '—'}</td>
|
||||
<td className="p-1.5 border-b border-gray-100 text-right font-mono text-[8pt]">{p.doneAt || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 7. Eingesetzte Mittel */}
|
||||
{d.fahrzeuge?.length > 0 && (
|
||||
<Section num="5" title="Eingesetzte Mittel">
|
||||
<Section num="7" title="Eingesetzte Mittel">
|
||||
<table className="w-full border-collapse border rounded text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-white">
|
||||
@@ -244,8 +288,8 @@ export default function RapportViewerPage({ params }: { params: { token: string
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 6. Bemerkungen */}
|
||||
<Section num="6" title="Bemerkungen / Besondere Vorkommnisse">
|
||||
{/* 8. Bemerkungen */}
|
||||
<Section num="8" title="Bemerkungen / Besondere Vorkommnisse">
|
||||
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ export default function RegisterPage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast({ title: 'Passwort muss mindestens 6 Zeichen haben', variant: 'destructive' })
|
||||
if (password.length < 8) {
|
||||
toast({ title: 'Passwort muss mindestens 8 Zeichen haben', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function RegisterPage() {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
|
||||
@@ -32,8 +32,8 @@ function ResetPasswordForm() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein.')
|
||||
if (password.length < 8) {
|
||||
setError('Passwort muss mindestens 8 Zeichen lang sein.')
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
@@ -108,7 +108,7 @@ function ResetPasswordForm() {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Min. 6 Zeichen"
|
||||
placeholder="Min. 8 Zeichen"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
|
||||
const tiers = [
|
||||
{ value: 5, label: 'Kaffee', emoji: '\u2615', desc: 'Ein Kaffee für die nächste Coding-Session' },
|
||||
{ value: 10, label: 'Pizza', emoji: '\uD83C\uDF55', desc: 'Pizza-Abend nach einem langen Entwicklungstag' },
|
||||
{ value: 25, label: 'Server', emoji: '\uD83D\uDDA5\uFE0F', desc: 'Hilft die monatlichen Serverkosten zu decken' },
|
||||
{ value: 10, label: 'Pizza', emoji: '\uD83C\uDF55', desc: 'Eine Pizza für die nächste Coding-Session' },
|
||||
{ value: 20, label: 'Server', emoji: '\uD83D\uDDA5\uFE0F', desc: 'Hilft die monatlichen Serverkosten zu decken' },
|
||||
{ value: 50, label: 'Feature', emoji: '\uD83D\uDE80', desc: 'Finanziert die Entwicklung eines neuen Features' },
|
||||
]
|
||||
|
||||
export default function SpendenPage() {
|
||||
const [amount, setAmount] = useState(10)
|
||||
const [amount, setAmount] = useState(20)
|
||||
const [customAmount, setCustomAmount] = useState('')
|
||||
const [isCustom, setIsCustom] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -112,9 +113,9 @@ export default function SpendenPage() {
|
||||
{tiers.map(tier => (
|
||||
<button
|
||||
key={tier.value}
|
||||
onClick={() => setAmount(tier.value)}
|
||||
onClick={() => { setAmount(tier.value); setIsCustom(false); setCustomAmount('') }}
|
||||
className={`rounded-xl p-4 text-center transition-all border-2 ${
|
||||
amount === tier.value
|
||||
!isCustom && amount === tier.value
|
||||
? 'border-red-500 bg-red-50 shadow-md'
|
||||
: 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
@@ -124,34 +125,52 @@ export default function SpendenPage() {
|
||||
<span className="text-xs text-gray-500 block mt-0.5">{tier.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setIsCustom(true)}
|
||||
className={`rounded-xl p-4 text-center transition-all border-2 ${
|
||||
isCustom
|
||||
? 'border-red-500 bg-red-50 shadow-md'
|
||||
: 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl block mb-1">{"\u2764\uFE0F"}</span>
|
||||
<span className="text-lg font-bold text-gray-900">Frei</span>
|
||||
<span className="text-xs text-gray-500 block mt-0.5">Eigener Betrag</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-500">Oder wähle einen eigenen Betrag:</span>
|
||||
<span className="text-xl font-bold text-red-600">CHF {amount}</span>
|
||||
{/* Custom amount input */}
|
||||
{isCustom && (
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Eigener Betrag in CHF</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium">CHF</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
step="1"
|
||||
value={customAmount}
|
||||
onChange={e => {
|
||||
setCustomAmount(e.target.value)
|
||||
const val = parseInt(e.target.value)
|
||||
if (val > 0) setAmount(val)
|
||||
}}
|
||||
placeholder="Betrag eingeben..."
|
||||
className="w-full rounded-lg border border-gray-300 pl-14 pr-4 py-3 text-lg font-bold focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="200"
|
||||
step="5"
|
||||
value={amount}
|
||||
onChange={e => setAmount(parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-red-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>CHF 5</span>
|
||||
<span>CHF 200</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current tier description */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6 text-center">
|
||||
<span className="text-3xl">{activeTier.emoji}</span>
|
||||
<p className="text-sm text-gray-600 mt-2">{activeTier.desc}</p>
|
||||
</div>
|
||||
{!isCustom && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6 text-center">
|
||||
<span className="text-3xl">{activeTier.emoji}</span>
|
||||
<p className="text-sm text-gray-600 mt-2">{activeTier.desc}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional name & message */}
|
||||
<div className="space-y-4 mb-6">
|
||||
@@ -203,14 +222,32 @@ export default function SpendenPage() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-4 text-xs text-gray-400">
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1"><Shield className="w-3.5 h-3.5" /> Sichere Zahlung via Stripe</span>
|
||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Kreditkarte & Twint</span>
|
||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Kreditkarte</span>
|
||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Twint</span>
|
||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Apple Pay</span>
|
||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Google Pay</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="mt-10 bg-gray-50 rounded-2xl p-6 md:p-8 border border-gray-100">
|
||||
<h3 className="font-bold text-gray-900 mb-3">Die Geschichte hinter Lageplan</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Lageplan wird seit über 2 Jahren von einem aktiven Feuerwehrmann in seiner Freizeit entwickelt.
|
||||
Die App ist und bleibt <strong>kostenlos</strong> — weil jede Feuerwehr Zugang zu guten Werkzeugen haben soll,
|
||||
unabhängig vom Budget. Es gibt zwar kommerzielle Alternativen, aber die Idee war immer:
|
||||
Ein Werkzeug <em>von der Feuerwehr, für die Feuerwehr</em>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mt-3">
|
||||
Mit deiner Spende hilfst du, dass das so bleibt. Jeder Franken fliesst direkt in
|
||||
Serverkosten und Weiterentwicklung. Danke für deine Unterstützung!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Trust */}
|
||||
<div className="mt-10 text-center">
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
100% deiner Spende fliesst direkt in Serverkosten und Weiterentwicklung.
|
||||
<br />Kein Unternehmen, keine Investoren — nur ein Feuerwehrmann mit einer Idee.
|
||||
|
||||
113
src/components/admin/dictionary-tab.tsx
Normal file
113
src/components/admin/dictionary-tab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { BookOpen, Plus, X } from 'lucide-react'
|
||||
import { apiFetch, ApiError } from '@/lib/api'
|
||||
|
||||
interface DictWord {
|
||||
id: string
|
||||
word: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export function DictionaryTab() {
|
||||
const { toast } = useToast()
|
||||
const [globalDictWords, setGlobalDictWords] = useState<DictWord[]>([])
|
||||
const [newGlobalWord, setNewGlobalWord] = useState('')
|
||||
const [dictLoading, setDictLoading] = useState(false)
|
||||
|
||||
const fetchGlobalDict = async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ words: DictWord[] }>('/api/dictionary?scope=GLOBAL', { silent: true })
|
||||
if (data?.words) setGlobalDictWords(data.words)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchGlobalDict()
|
||||
}, [])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newGlobalWord.trim()) return
|
||||
setDictLoading(true)
|
||||
try {
|
||||
await apiFetch('/api/dictionary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ word: newGlobalWord.trim(), scope: 'GLOBAL' }),
|
||||
})
|
||||
setNewGlobalWord('')
|
||||
fetchGlobalDict()
|
||||
toast({ title: 'Begriff hinzugefügt' })
|
||||
} catch (err) {
|
||||
toast({ title: 'Fehler', description: err instanceof ApiError ? err.message : 'Fehler', variant: 'destructive' })
|
||||
} finally { setDictLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
Globales Wörterbuch
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Globale Begriffe, die allen Mandanten als Journal-Vorschläge zur Verfügung stehen.
|
||||
Mandanten können zusätzlich eigene Begriffe über ihre Wörterliste hinzufügen.
|
||||
</p>
|
||||
|
||||
{/* Add new word */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="Neuer globaler Begriff, z.B. 'Leitung aufbauen'..."
|
||||
value={newGlobalWord}
|
||||
onChange={(e) => setNewGlobalWord(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === 'Enter' && newGlobalWord.trim()) handleAdd()
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={dictLoading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={!newGlobalWord.trim() || dictLoading}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List of global words */}
|
||||
{globalDictWords.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
|
||||
Noch keine globalen Begriffe hinterlegt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{globalDictWords.map((w) => (
|
||||
<span key={w.id} className="inline-flex items-center gap-1 px-3 py-1.5 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300 rounded-full text-sm border border-green-200 dark:border-green-800">
|
||||
{w.word}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch(`/api/dictionary/${w.id}`, { method: 'DELETE' })
|
||||
fetchGlobalDict()
|
||||
toast({ title: 'Entfernt' })
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
{globalDictWords.length} globale(r) Begriff(e). Diese erscheinen bei allen Mandanten als Vorschläge im Journal.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
src/components/admin/org-tab.tsx
Normal file
246
src/components/admin/org-tab.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
Building2, Upload, X, Loader2, Shield, Trash2, AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface OrgTabProps {
|
||||
tenantId?: string | null
|
||||
}
|
||||
|
||||
export function OrgTab({ tenantId }: OrgTabProps) {
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false)
|
||||
const [tenant, setTenant] = useState<any>(null)
|
||||
|
||||
// Editable fields
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
const [contactPhone, setContactPhone] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
|
||||
const fetchTenant = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/tenant/info')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const t = data.tenant
|
||||
if (t) {
|
||||
setTenant(t)
|
||||
setName(t.name || '')
|
||||
setDescription(t.description || '')
|
||||
setContactEmail(t.contactEmail || '')
|
||||
setContactPhone(t.contactPhone || '')
|
||||
setAddress(t.address || '')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load tenant info:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenant()
|
||||
}, [tenantId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/tenant/info', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
contactEmail: contactEmail.trim() || null,
|
||||
contactPhone: contactPhone.trim() || null,
|
||||
address: address.trim() || null,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast({ title: 'Organisation aktualisiert' })
|
||||
fetchTenant()
|
||||
} else {
|
||||
const err = await res.json()
|
||||
toast({ title: 'Fehler', description: err.error || 'Speichern fehlgeschlagen', variant: 'destructive' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Fehler', description: 'Verbindungsfehler', variant: 'destructive' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.[0]) return
|
||||
setUploadingLogo(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('logo', e.target.files[0])
|
||||
const res = await fetch('/api/tenant/logo', { method: 'POST', body: formData })
|
||||
if (res.ok) {
|
||||
toast({ title: 'Logo hochgeladen' })
|
||||
fetchTenant()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
toast({ title: 'Fehler', description: data.error || 'Upload fehlgeschlagen', variant: 'destructive' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Fehler', description: 'Upload fehlgeschlagen', variant: 'destructive' })
|
||||
} finally {
|
||||
setUploadingLogo(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogoDelete = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/tenant/logo', { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
toast({ title: 'Logo entfernt' })
|
||||
fetchTenant()
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Fehler', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!tenant) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
Keine Organisation zugeordnet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Logo */}
|
||||
<div className="border rounded-lg p-5">
|
||||
<h3 className="font-semibold text-base mb-3 flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4" />
|
||||
Logo
|
||||
</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||
{tenant.logoUrl ? (
|
||||
<img
|
||||
src={tenant.logoUrl.startsWith('/') ? tenant.logoUrl : `/api/tenant/logo/serve`}
|
||||
alt="Logo"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="w-10 h-10 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="relative" disabled={uploadingLogo}>
|
||||
{uploadingLogo ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-1" />}
|
||||
{tenant.logoUrl ? 'Ändern' : 'Hochladen'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
onChange={handleLogoUpload}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</Button>
|
||||
{tenant.logoUrl && (
|
||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={handleLogoDelete}>
|
||||
<X className="w-3.5 h-3.5 mr-1" /> Entfernen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">PNG, JPEG, SVG oder WebP, max. 2 MB. Wird auch im Rapport angezeigt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organisation Details */}
|
||||
<div className="border rounded-lg p-5 space-y-4">
|
||||
<h3 className="font-semibold text-base mb-1 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
Stammdaten
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Slug</Label>
|
||||
<Input value={tenant.slug} disabled className="font-mono bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Beschreibung</Label>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="z.B. Feuerwehr Musterstadt" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">Kontakt E-Mail</Label>
|
||||
<Input type="email" value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="kontakt@feuerwehr.ch" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Kontakt Telefon</Label>
|
||||
<Input value={contactPhone} onChange={e => setContactPhone(e.target.value)} placeholder="+41 ..." />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Adresse</Label>
|
||||
<Input value={address} onChange={e => setAddress(e.target.value)} placeholder="Strasse, PLZ Ort" />
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info (read-only) */}
|
||||
<div className="border rounded-lg p-5">
|
||||
<h3 className="font-semibold text-base mb-3">Übersicht</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Plan:</span>
|
||||
<span className="ml-2">{tenant.plan}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<span className="ml-2">{tenant.subscriptionStatus}</span>
|
||||
</div>
|
||||
{tenant._count && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Benutzer:</span>
|
||||
<span className="ml-2">{tenant._count.memberships}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Einsätze:</span>
|
||||
<span className="ml-2">{tenant._count.projects}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
450
src/components/admin/settings-tab.tsx
Normal file
450
src/components/admin/settings-tab.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Mail, Send, CheckCircle, Ban, CreditCard, Map, MapPin, Settings,
|
||||
Shield, UserPlus, ArrowLeft, Loader2,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SettingsTabProps {
|
||||
usersCount: number
|
||||
tenantsCount: number
|
||||
iconsCount: number
|
||||
onNavigateTab: (tab: string) => void
|
||||
}
|
||||
|
||||
export function SettingsTab({ usersCount, tenantsCount, iconsCount, onNavigateTab }: SettingsTabProps) {
|
||||
const { toast } = useToast()
|
||||
|
||||
// SMTP Settings
|
||||
const [smtpHost, setSmtpHost] = useState('')
|
||||
const [smtpPort, setSmtpPort] = useState('587')
|
||||
const [smtpSecure, setSmtpSecure] = useState(false)
|
||||
const [smtpUser, setSmtpUser] = useState('')
|
||||
const [smtpPass, setSmtpPass] = useState('')
|
||||
const [smtpFromName, setSmtpFromName] = useState('Lageplan')
|
||||
const [smtpFromEmail, setSmtpFromEmail] = useState('')
|
||||
const [smtpTestEmail, setSmtpTestEmail] = useState('')
|
||||
const [smtpLoading, setSmtpLoading] = useState(false)
|
||||
const [smtpStatus, setSmtpStatus] = useState<string | null>(null)
|
||||
const [contactEmail, setContactEmail] = useState('app@lageplan.ch')
|
||||
const [notifyRegistrationEmail, setNotifyRegistrationEmail] = useState('')
|
||||
|
||||
// Stripe Settings
|
||||
const [stripePublicKey, setStripePublicKey] = useState('')
|
||||
const [stripeSecretKey, setStripeSecretKey] = useState('')
|
||||
const [stripeWebhookSecret, setStripeWebhookSecret] = useState('')
|
||||
const [stripeLoading, setStripeLoading] = useState(false)
|
||||
const [stripeStatus, setStripeStatus] = useState<string | null>(null)
|
||||
|
||||
// Demo Project
|
||||
const [demoProjectId, setDemoProjectId] = useState('')
|
||||
const [allProjects, setAllProjects] = useState<{ id: string; title: string; location?: string }[]>([])
|
||||
const [demoLoading, setDemoLoading] = useState(false)
|
||||
const [demoStatus, setDemoStatus] = useState<string | null>(null)
|
||||
|
||||
// Default Symbol Scale
|
||||
const [defaultSymbolScale, setDefaultSymbolScale] = useState('1.5')
|
||||
const [symbolScaleLoading, setSymbolScaleLoading] = useState(false)
|
||||
const [symbolScaleStatus, setSymbolScaleStatus] = useState<string | null>(null)
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/settings').then(r => r.json()).then(data => {
|
||||
if (data.smtp) {
|
||||
setSmtpHost(data.smtp.host || '')
|
||||
setSmtpPort(data.smtp.port?.toString() || '587')
|
||||
setSmtpSecure(data.smtp.secure || false)
|
||||
setSmtpUser(data.smtp.user || '')
|
||||
setSmtpFromName(data.smtp.fromName || 'Lageplan')
|
||||
setSmtpFromEmail(data.smtp.fromEmail || '')
|
||||
}
|
||||
if (data.stripe) {
|
||||
setStripePublicKey(data.stripe.publicKey || '')
|
||||
setStripeSecretKey(data.stripe.secretKey ? '••••••••' : '')
|
||||
setStripeWebhookSecret(data.stripe.webhookSecret ? '••••••••' : '')
|
||||
}
|
||||
if (data.contactEmail) setContactEmail(data.contactEmail)
|
||||
if (data.notifyRegistrationEmail) setNotifyRegistrationEmail(data.notifyRegistrationEmail)
|
||||
if (data.demoProjectId) setDemoProjectId(data.demoProjectId)
|
||||
if (data.defaultSymbolScale) setDefaultSymbolScale(data.defaultSymbolScale.toString())
|
||||
}).catch(() => {})
|
||||
|
||||
// Load projects for demo selector
|
||||
fetch('/api/projects').then(r => r.json()).then(data => {
|
||||
if (data.projects) setAllProjects(data.projects)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSmtpSave = async () => {
|
||||
setSmtpLoading(true)
|
||||
setSmtpStatus(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'save_smtp',
|
||||
smtp: { host: smtpHost, port: parseInt(smtpPort), secure: smtpSecure, user: smtpUser, pass: smtpPass, fromName: smtpFromName, fromEmail: smtpFromEmail },
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
toast({ title: 'SMTP gespeichert' })
|
||||
setSmtpStatus('saved')
|
||||
} else throw new Error(data.error)
|
||||
} catch (error) {
|
||||
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||
} finally { setSmtpLoading(false) }
|
||||
}
|
||||
|
||||
const handleSmtpTest = async () => {
|
||||
setSmtpLoading(true)
|
||||
setSmtpStatus(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'test_smtp' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setSmtpStatus('connected')
|
||||
toast({ title: 'SMTP-Verbindung erfolgreich' })
|
||||
} else {
|
||||
setSmtpStatus('error')
|
||||
toast({ title: 'Verbindung fehlgeschlagen', description: data.error, variant: 'destructive' })
|
||||
}
|
||||
} catch (error) {
|
||||
setSmtpStatus('error')
|
||||
toast({ title: 'Fehler', variant: 'destructive' })
|
||||
} finally { setSmtpLoading(false) }
|
||||
}
|
||||
|
||||
const handleSmtpSendTest = async () => {
|
||||
if (!smtpTestEmail) return
|
||||
setSmtpLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'send_test_email', testEmail: smtpTestEmail }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) toast({ title: data.message })
|
||||
else toast({ title: 'Senden fehlgeschlagen', description: data.error, variant: 'destructive' })
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
finally { setSmtpLoading(false) }
|
||||
}
|
||||
|
||||
const handleContactEmailSave = async () => {
|
||||
setSmtpLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'save_contact_email', contactEmail }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) toast({ title: 'Kontakt-E-Mail gespeichert' })
|
||||
else throw new Error(data.error)
|
||||
} catch (error) {
|
||||
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||
} finally { setSmtpLoading(false) }
|
||||
}
|
||||
|
||||
const handleStripeSave = async () => {
|
||||
setStripeLoading(true)
|
||||
setStripeStatus(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'save_stripe',
|
||||
stripe: {
|
||||
publicKey: stripePublicKey,
|
||||
secretKey: stripeSecretKey,
|
||||
webhookSecret: stripeWebhookSecret,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setStripeStatus('saved')
|
||||
toast({ title: 'Stripe-Einstellungen gespeichert' })
|
||||
} else throw new Error(data.error)
|
||||
} catch (error) {
|
||||
setStripeStatus('error')
|
||||
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||
} finally { setStripeLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Contact Email */}
|
||||
<div className="border rounded-lg p-6 md:col-span-2">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-primary" />
|
||||
Kontakt-E-Mail
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse für das Kontaktformular auf der Landing Page. Hierhin werden Anfragen gesendet.</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1"><Label>Empfänger-Adresse</Label><Input value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="app@lageplan.ch" /></div>
|
||||
<Button onClick={handleContactEmailSave} disabled={smtpLoading}>Speichern</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Notification */}
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-primary" />
|
||||
Registrierungs-Benachrichtigung
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1"><Label>Admin-E-Mail</Label><Input value={notifyRegistrationEmail} onChange={e => setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" /></div>
|
||||
<Button onClick={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'save_setting', key: 'notify_registration_email', value: notifyRegistrationEmail }),
|
||||
})
|
||||
if ((await res.json()).success) toast({ title: 'Gespeichert' })
|
||||
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
|
||||
}} disabled={smtpLoading}>Speichern</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP Settings */}
|
||||
<div className="border rounded-lg p-6 md:col-span-2">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-primary" />
|
||||
E-Mail / SMTP Konfiguration
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Passwörter werden verschlüsselt in der Datenbank gespeichert.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div><Label>SMTP Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" /></div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div><Label>Port</Label><Input value={smtpPort} onChange={e => setSmtpPort(e.target.value)} placeholder="587" /></div>
|
||||
<div className="flex items-end gap-2 pb-0.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={smtpSecure} onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
|
||||
SSL/TLS
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div><Label>Benutzername</Label><Input value={smtpUser} onChange={e => setSmtpUser(e.target.value)} placeholder="user@example.com" /></div>
|
||||
<div><Label>Passwort</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} placeholder="App-Passwort oder SMTP-Passwort" /></div>
|
||||
<div><Label>Absender-Name</Label><Input value={smtpFromName} onChange={e => setSmtpFromName(e.target.value)} placeholder="Lageplan" /></div>
|
||||
<div><Label>Absender-E-Mail</Label><Input value={smtpFromEmail} onChange={e => setSmtpFromEmail(e.target.value)} placeholder="noreply@lageplan.ch" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button onClick={handleSmtpSave} disabled={smtpLoading || !smtpHost || !smtpUser}>
|
||||
{smtpLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||
Speichern
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleSmtpTest} disabled={smtpLoading || !smtpHost}>
|
||||
Verbindung testen
|
||||
</Button>
|
||||
{smtpStatus === 'connected' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Verbunden</span>}
|
||||
{smtpStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
|
||||
{smtpStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||
</div>
|
||||
<div className="border-t mt-4 pt-4">
|
||||
<Label className="text-sm font-medium">Test-E-Mail senden</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Input value={smtpTestEmail} onChange={e => setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" />
|
||||
<Button variant="outline" onClick={handleSmtpSendTest} disabled={smtpLoading || !smtpTestEmail}>
|
||||
<Send className="w-4 h-4 mr-1.5" />
|
||||
Senden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe Settings */}
|
||||
<div className="border rounded-lg p-6 md:col-span-2">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-primary" />
|
||||
Stripe / Spenden-Konfiguration
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Stripe API-Keys für die Spendenseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
|
||||
Keys findest du im <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-primary underline">Stripe Dashboard</a>.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div><Label>Publishable Key (pk_...)</Label><Input value={stripePublicKey} onChange={e => setStripePublicKey(e.target.value)} placeholder="pk_live_..." /></div>
|
||||
<div><Label>Secret Key (sk_...)</Label><Input type="password" value={stripeSecretKey} onChange={e => setStripeSecretKey(e.target.value)} placeholder="sk_live_..." /></div>
|
||||
<div className="md:col-span-2"><Label>Webhook Secret (whsec_...) — optional</Label><Input type="password" value={stripeWebhookSecret} onChange={e => setStripeWebhookSecret(e.target.value)} placeholder="whsec_..." /></div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Webhook-Endpoint: <code className="bg-muted px-1.5 py-0.5 rounded text-xs">{typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook</code>
|
||||
</p>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button onClick={handleStripeSave} disabled={stripeLoading || !stripePublicKey || !stripeSecretKey}>
|
||||
{stripeLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||
Speichern
|
||||
</Button>
|
||||
{stripeStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||
{stripeStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Project */}
|
||||
<div className="border rounded-lg p-6 md:col-span-2">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Map className="w-5 h-5 text-primary" />
|
||||
Live-Demo auf der Startseite
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Wähle ein Projekt als Demo-Karte für die Landing Page. Besucher können die Karte sehen und zoomen, aber nichts bearbeiten.
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Label>Demo-Projekt</Label>
|
||||
<select
|
||||
value={demoProjectId}
|
||||
onChange={e => setDemoProjectId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">— Keine Demo —</option>
|
||||
{allProjects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.title}{p.location ? ` (${p.location})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setDemoLoading(true)
|
||||
setDemoStatus(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'save_demo_project', demoProjectId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
toast({ title: 'Demo-Projekt gespeichert' })
|
||||
setDemoStatus('saved')
|
||||
} else throw new Error(data.error)
|
||||
} catch (error) {
|
||||
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
|
||||
setDemoStatus('error')
|
||||
} finally { setDemoLoading(false) }
|
||||
}}
|
||||
disabled={demoLoading}
|
||||
>
|
||||
{demoLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||
Speichern
|
||||
</Button>
|
||||
{demoStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||
</div>
|
||||
{demoProjectId && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Vorschau: <a href="/demo" target="_blank" rel="noopener noreferrer" className="text-primary underline">/demo</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Symbol-Grösse */}
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
Standard Symbol-Grösse
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Bestimmt die Standard-Grösse neuer Symbole auf der Karte. Kleinere Werte = kleinere Symbole.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.1"
|
||||
value={defaultSymbolScale}
|
||||
onChange={e => setDefaultSymbolScale(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-lg font-bold w-16 text-center">{defaultSymbolScale}x</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4">
|
||||
<span>0.5x (klein)</span>
|
||||
<span className="flex-1" />
|
||||
<span>5x (gross)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={symbolScaleLoading}
|
||||
onClick={async () => {
|
||||
setSymbolScaleLoading(true)
|
||||
setSymbolScaleStatus(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'save_setting', key: 'default_symbol_scale', value: defaultSymbolScale }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) setSymbolScaleStatus('saved')
|
||||
} catch {} finally { setSymbolScaleLoading(false) }
|
||||
}}
|
||||
>
|
||||
{symbolScaleLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
|
||||
Speichern
|
||||
</Button>
|
||||
{symbolScaleStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Info */}
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-primary" />
|
||||
System-Info
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Version</span><span className="font-medium">1.0.0</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Framework</span><span className="font-medium">Next.js 14.1</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Datenbank</span><span className="font-medium">PostgreSQL 16</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Benutzer</span><span className="font-medium">{usersCount}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Mandanten</span><span className="font-medium">{tenantsCount}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Symbole</span><span className="font-medium">{iconsCount}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
Schnellaktionen
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('tenants')}>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Mandanten verwalten
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('users')}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Benutzer anlegen
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<Link href="/app">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Zur Krokier-App
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
src/components/admin/soma-tab.tsx
Normal file
147
src/components/admin/soma-tab.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertTriangle, Eye, EyeOff, Trash2, Plus, Loader2, GripVertical } from 'lucide-react'
|
||||
|
||||
interface SomaTemplate {
|
||||
id: string
|
||||
label: string
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export function SomaTab() {
|
||||
const { toast } = useToast()
|
||||
const [somaTemplates, setSomaTemplates] = useState<SomaTemplate[]>([])
|
||||
const [newSomaLabel, setNewSomaLabel] = useState('')
|
||||
const [somaLoading, setSomaLoading] = useState(false)
|
||||
|
||||
const fetchSomaTemplates = async () => {
|
||||
setSomaLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/tenant/soma-templates')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSomaTemplates(data.templates || [])
|
||||
}
|
||||
} catch {}
|
||||
setSomaLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSomaTemplates()
|
||||
}, [])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newSomaLabel.trim()) return
|
||||
try {
|
||||
await fetch('/api/tenant/soma-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: newSomaLabel.trim(), sortOrder: somaTemplates.length }),
|
||||
})
|
||||
setNewSomaLabel('')
|
||||
fetchSomaTemplates()
|
||||
toast({ title: 'SOMA-Vorlage hinzugefügt' })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
SOMA-Checkliste verwalten
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Definiere die Sofortmassnahmen (SOMA), die bei jedem neuen Einsatz als Checkliste erscheinen.
|
||||
Bestehende Einsätze werden nicht verändert.
|
||||
</p>
|
||||
|
||||
{somaLoading ? (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Laden...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Template list */}
|
||||
<div className="border rounded-lg divide-y">
|
||||
{somaTemplates.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
Keine SOMA-Vorlagen definiert. Neue Einsätze starten ohne Checkliste.
|
||||
</div>
|
||||
) : somaTemplates.map((tpl, idx) => (
|
||||
<div key={tpl.id} className={`flex items-center gap-3 px-4 py-2.5 ${!tpl.isActive ? 'opacity-50' : ''}`}>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
|
||||
<span className="text-sm font-medium flex-1">{tpl.label}</span>
|
||||
<span className="text-xs text-muted-foreground">#{idx + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch('/api/tenant/soma-templates', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates: [{ id: tpl.id, isActive: !tpl.isActive }] }),
|
||||
})
|
||||
fetchSomaTemplates()
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
{tpl.isActive ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-destructive hover:text-destructive"
|
||||
onClick={async () => {
|
||||
if (!confirm(`"${tpl.label}" wirklich löschen?`)) return
|
||||
try {
|
||||
await fetch('/api/tenant/soma-templates', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: tpl.id }),
|
||||
})
|
||||
fetchSomaTemplates()
|
||||
toast({ title: 'SOMA-Vorlage gelöscht' })
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add new */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Input
|
||||
placeholder="Neue Sofortmassnahme..."
|
||||
value={newSomaLabel}
|
||||
onChange={e => setNewSomaLabel(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newSomaLabel.trim()) {
|
||||
e.preventDefault()
|
||||
handleAdd()
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button disabled={!newSomaLabel.trim()} onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-1" /> Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
{somaTemplates.filter(t => t.isActive).length} aktiv / {somaTemplates.length} gesamt —
|
||||
Nur aktive Vorlagen erscheinen bei neuen Einsätzen.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
src/components/admin/suggestions-tab.tsx
Normal file
150
src/components/admin/suggestions-tab.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { BookOpen, Plus, X, Download, Upload } from 'lucide-react'
|
||||
|
||||
interface SuggestionsTabProps {
|
||||
tenantId: string | undefined
|
||||
}
|
||||
|
||||
export function SuggestionsTab({ tenantId }: SuggestionsTabProps) {
|
||||
const { toast } = useToast()
|
||||
const [journalSuggestions, setJournalSuggestions] = useState<string[]>([])
|
||||
const [newSuggestion, setNewSuggestion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantId) return
|
||||
fetch(`/api/tenants/${tenantId}/suggestions`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.suggestions) setJournalSuggestions(data.suggestions) })
|
||||
.catch(() => {})
|
||||
}, [tenantId])
|
||||
|
||||
const saveSuggestions = (updated: string[]) => {
|
||||
if (!tenantId) return
|
||||
fetch(`/api/tenants/${tenantId}/suggestions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ suggestions: updated }),
|
||||
}).then(r => { if (r.ok) toast({ title: 'Gespeichert' }) })
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmed = newSuggestion.trim()
|
||||
if (!trimmed || journalSuggestions.includes(trimmed)) return
|
||||
const updated = [...journalSuggestions, trimmed].sort((a, b) => a.localeCompare(b, 'de'))
|
||||
setJournalSuggestions(updated)
|
||||
setNewSuggestion('')
|
||||
saveSuggestions(updated)
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const updated = journalSuggestions.filter((_, idx) => idx !== index)
|
||||
setJournalSuggestions(updated)
|
||||
saveSuggestions(updated)
|
||||
toast({ title: 'Entfernt' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
Journal-Wörterliste
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Häufige Begriffe und Textbausteine, die beim Erfassen von Journal-Einträgen als Vorschläge erscheinen.
|
||||
Wenn der Benutzer im "Was..."-Feld tippt, werden passende Begriffe vorgeschlagen.
|
||||
</p>
|
||||
|
||||
{/* Add new suggestion */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="Neuer Begriff, z.B. 'Leitung aufbauen'..."
|
||||
value={newSuggestion}
|
||||
onChange={(e) => setNewSuggestion(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newSuggestion.trim()) handleAdd()
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAdd} disabled={!newSuggestion.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List of suggestions */}
|
||||
{journalSuggestions.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
|
||||
Noch keine Begriffe hinterlegt. Fügen Sie häufig verwendete Textbausteine hinzu,<br />
|
||||
z.B. "Leitung aufbauen", "Leitung abbauen", "Lüfter in Stellung", etc.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{journalSuggestions.map((s, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 rounded-full text-sm border border-blue-200 dark:border-blue-800">
|
||||
{s}
|
||||
<button
|
||||
onClick={() => handleRemove(i)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
|
||||
<p className="text-xs text-muted-foreground flex-1">
|
||||
{journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const blob = new Blob([journalSuggestions.join('\n')], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'woerterliste.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast({ title: 'Exportiert', description: `${journalSuggestions.length} Begriffe exportiert` })
|
||||
}}
|
||||
disabled={journalSuggestions.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.txt,.csv'
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
const text = await file.text()
|
||||
const words = text.split(/[\n\r,;]+/).map(w => w.trim()).filter(Boolean)
|
||||
if (words.length === 0) { toast({ title: 'Keine Begriffe gefunden', variant: 'destructive' }); return }
|
||||
const merged = Array.from(new Set([...journalSuggestions, ...words])).sort((a, b) => a.localeCompare(b, 'de'))
|
||||
setJournalSuggestions(merged)
|
||||
saveSuggestions(merged)
|
||||
toast({ title: 'Importiert', description: `${words.length} Begriffe importiert (${merged.length} total)` })
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
372
src/components/admin/symbol-manager.tsx
Normal file
372
src/components/admin/symbol-manager.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
Upload,
|
||||
Loader2,
|
||||
X,
|
||||
Check,
|
||||
LayoutGrid,
|
||||
ImageIcon,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LibraryIcon {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
iconType: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
}
|
||||
|
||||
interface MySymbol {
|
||||
id: string
|
||||
iconId: string
|
||||
name: string
|
||||
customName: string | null
|
||||
baseName: string
|
||||
mimeType: string
|
||||
iconType: string
|
||||
categoryName: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export function SymbolManager() {
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [library, setLibrary] = useState<LibraryIcon[]>([])
|
||||
const [mySymbols, setMySymbols] = useState<MySymbol[]>([])
|
||||
|
||||
// Library UI state
|
||||
const [librarySearch, setLibrarySearch] = useState('')
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
const [libraryCollapsed, setLibraryCollapsed] = useState(false)
|
||||
|
||||
// My Symbols UI state
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/tenant/symbols')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLibrary(data.library || [])
|
||||
setMySymbols(data.mySymbols || [])
|
||||
// Auto-collapse library if tenant has own symbols
|
||||
if ((data.mySymbols || []).length > 0) {
|
||||
setLibraryCollapsed(true)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
// Add symbol from library to my collection
|
||||
const addSymbol = async (iconId: string, customName?: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/tenant/symbols', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ iconId, customName }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const symbol = await res.json()
|
||||
setMySymbols(prev => [...prev, symbol])
|
||||
toast({ title: 'Symbol hinzugefügt' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Fehler', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
// Rename a symbol
|
||||
const renameSymbol = async (id: string, customName: string) => {
|
||||
try {
|
||||
await fetch('/api/tenant/symbols', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, customName }),
|
||||
})
|
||||
setMySymbols(prev => prev.map(s =>
|
||||
s.id === id ? { ...s, name: customName || s.baseName, customName: customName || null } : s
|
||||
))
|
||||
setEditingId(null)
|
||||
setEditName('')
|
||||
toast({ title: 'Umbenannt' })
|
||||
} catch {
|
||||
toast({ title: 'Fehler', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a symbol
|
||||
const removeSymbol = async (id: string) => {
|
||||
try {
|
||||
await fetch('/api/tenant/symbols', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
setMySymbols(prev => prev.filter(s => s.id !== id))
|
||||
toast({ title: 'Symbol entfernt' })
|
||||
} catch {
|
||||
toast({ title: 'Fehler', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle category expand/collapse
|
||||
const toggleCategory = (cat: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(cat) ? next.delete(cat) : next.add(cat)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Group library icons by category
|
||||
const filteredLibrary = library.filter(icon =>
|
||||
!librarySearch || icon.name.toLowerCase().includes(librarySearch.toLowerCase())
|
||||
)
|
||||
const libraryGrouped = filteredLibrary.reduce<Record<string, LibraryIcon[]>>((acc, icon) => {
|
||||
const key = icon.categoryName
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(icon)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> Symbole laden...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ===== MEINE SYMBOLE (always on top, prominent) ===== */}
|
||||
<div className="border-2 border-primary/20 rounded-lg">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-primary/5 border-b border-primary/20">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<LayoutGrid className="w-4 h-4 text-primary" />
|
||||
Meine Symbole
|
||||
<span className="text-xs text-muted-foreground font-normal">({mySymbols.length})</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{mySymbols.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-1">Noch keine eigenen Symbole definiert.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Füge Symbole aus der Bibliothek unten hinzu oder lade eigene SVGs hoch.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
||||
{mySymbols.map(sym => (
|
||||
<div
|
||||
key={sym.id}
|
||||
className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30"
|
||||
>
|
||||
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
|
||||
<img
|
||||
src={`/api/icons/${sym.iconId}/image`}
|
||||
alt={sym.name}
|
||||
className="w-12 h-12 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name / Edit */}
|
||||
{editingId === sym.id ? (
|
||||
<div className="flex gap-0.5">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') renameSymbol(sym.id, editName)
|
||||
if (e.key === 'Escape') { setEditingId(null); setEditName('') }
|
||||
}}
|
||||
className="h-6 text-[10px] px-1"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => renameSymbol(sym.id, editName)} className="text-green-600 hover:text-green-700">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { setEditingId(null); setEditName('') }} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="text-[11px] text-center truncate cursor-pointer hover:text-primary"
|
||||
title={`${sym.name}${sym.customName ? ` (Basis: ${sym.baseName})` : ''} — Klick zum Umbenennen`}
|
||||
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
|
||||
>
|
||||
{sym.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
|
||||
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-primary"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeSymbol(sym.id)}
|
||||
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
|
||||
title="Entfernen"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom name badge */}
|
||||
{sym.customName && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<div className="w-2 h-2 rounded-full bg-primary" title="Eigener Name" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== UPLOAD HINWEIS ===== */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>Tipp:</strong> Eigene Symbole am besten als <strong>SVG</strong> hochladen — diese werden in jeder Grösse scharf dargestellt.
|
||||
PNG/JPEG sind auch möglich, können aber bei Vergrösserung unscharf werden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== STANDARD-BIBLIOTHEK (collapsible) ===== */}
|
||||
<div className="border rounded-lg">
|
||||
<button
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setLibraryCollapsed(!libraryCollapsed)}
|
||||
>
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
{libraryCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
Standard-Bibliothek
|
||||
<span className="text-xs text-muted-foreground font-normal">({library.length} Symbole)</span>
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{libraryCollapsed ? 'Aufklappen' : 'Zuklappen'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!libraryCollapsed && (
|
||||
<div className="border-t px-4 py-3 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Symbole suchen..."
|
||||
value={librarySearch}
|
||||
onChange={e => setLibrarySearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => (
|
||||
<div key={catName} className="border rounded-lg">
|
||||
<button
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/30 transition-colors text-sm"
|
||||
onClick={() => toggleCategory(catName)}
|
||||
>
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
{expandedCategories.has(catName) ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
{catName}
|
||||
<span className="text-xs text-muted-foreground font-normal">({icons.length})</span>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Add all icons from this category
|
||||
icons.forEach(icon => addSymbol(icon.id))
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" /> Alle hinzufügen
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
{expandedCategories.has(catName) && (
|
||||
<div className="border-t px-3 py-3">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
|
||||
{icons.map(icon => {
|
||||
const alreadyAdded = mySymbols.some(s => s.iconId === icon.id)
|
||||
return (
|
||||
<button
|
||||
key={icon.id}
|
||||
onClick={() => addSymbol(icon.id)}
|
||||
className={`group relative border rounded-lg p-2 transition-all hover:shadow-sm hover:border-primary/40 ${
|
||||
alreadyAdded ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' : ''
|
||||
}`}
|
||||
title={`${icon.name} — Klick zum Hinzufügen`}
|
||||
>
|
||||
<div className="aspect-square flex items-center justify-center mb-1 bg-muted/30 rounded">
|
||||
<img
|
||||
src={`/api/icons/${icon.id}/image`}
|
||||
alt={icon.name}
|
||||
className="w-10 h-10 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-center truncate">{icon.name}</p>
|
||||
{/* Add overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity">
|
||||
<Plus className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
{/* Already added indicator */}
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1 right-1 w-3 h-3 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<Check className="w-2 h-2 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.keys(libraryGrouped).length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-6 text-sm">
|
||||
{librarySearch ? 'Keine Symbole gefunden.' : 'Keine Standard-Symbole vorhanden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { MapPin, Loader2, X } from 'lucide-react'
|
||||
import type { Project } from '@/app/app/page'
|
||||
import type { Project } from '@/types'
|
||||
|
||||
interface NominatimResult {
|
||||
place_id: number
|
||||
|
||||
58
src/components/error-boundary.tsx
Normal file
58
src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AlertTriangle, RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Caught error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[200px] p-8 text-center">
|
||||
<AlertTriangle className="w-10 h-10 text-destructive mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Etwas ist schiefgelaufen</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
{this.state.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}
|
||||
</p>
|
||||
<Button variant="outline" onClick={this.handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
||||
} from 'lucide-react'
|
||||
import { getSocket } from '@/lib/socket'
|
||||
import { RapportDialog } from '@/components/journal/rapport-dialog'
|
||||
|
||||
interface JournalEntry {
|
||||
id: string
|
||||
@@ -86,7 +87,6 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Rapport creation
|
||||
const [creatingRapport, setCreatingRapport] = useState(false)
|
||||
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
|
||||
const [showRapportDialog, setShowRapportDialog] = useState(false)
|
||||
const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
|
||||
@@ -461,8 +461,21 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
||||
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
|
||||
lageEintreffen: '',
|
||||
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
|
||||
somaItems: checkItems.map(c => ({
|
||||
label: c.label,
|
||||
confirmed: c.confirmed,
|
||||
ok: c.ok,
|
||||
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
|
||||
})),
|
||||
pendenzenItems: pendenzen.map(p => ({
|
||||
what: p.what,
|
||||
who: p.who || '',
|
||||
whenHow: p.whenHow || '',
|
||||
done: p.done,
|
||||
doneAt: p.doneAt ? formatTime(p.doneAt) : null,
|
||||
})),
|
||||
fahrzeuge: [] as any[],
|
||||
bemerkungen: pendenzen.filter(p => !p.done).map(p => `PENDENT: ${p.what}${p.who ? ` (${p.who})` : ''}`).join('\n'),
|
||||
bemerkungen: '',
|
||||
einsatzleiter: einsatzleiter || '',
|
||||
rapporteur: journalfuehrer || '',
|
||||
reportNumber: '',
|
||||
@@ -895,221 +908,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
|
||||
</div>
|
||||
|
||||
{/* Rapport Dialog */}
|
||||
{showRapportDialog && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-2xl w-full max-w-2xl mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Einsatzrapport erstellen
|
||||
</h3>
|
||||
<button onClick={() => setShowRapportDialog(false)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
|
||||
{/* Organisation */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Organisation</label>
|
||||
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Abteilung</label>
|
||||
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Einsatzdaten */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Datum</label>
|
||||
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Uhrzeit</label>
|
||||
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatz-Nr.</label>
|
||||
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Priorität</label>
|
||||
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Ort */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzort / Adresse</label>
|
||||
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Objekt / Gebäude</label>
|
||||
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Koordinaten</label>
|
||||
<Input value={rapportForm.koordinaten || ''} onChange={e => setRapportForm(f => ({ ...f, koordinaten: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Alarmierungsart</label>
|
||||
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Stichwort / Meldebild</label>
|
||||
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
|
||||
</div>
|
||||
{/* Zeitverlauf */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase mb-1 block">Zeitverlauf</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
['zeitAlarm', 'Alarm'],
|
||||
['zeitAusruecken', 'Ausrücken'],
|
||||
['zeitEintreffen', 'Eintreffen'],
|
||||
['zeitBereit', 'Bereit'],
|
||||
['zeitKontrolle', 'F. u. Kontrolle'],
|
||||
['zeitAus', 'F. aus'],
|
||||
['zeitEinruecken', 'Einrücken'],
|
||||
['zeitEnde', 'Ende'],
|
||||
].map(([key, label]) => (
|
||||
<div key={key}>
|
||||
<label className="text-[10px] text-gray-400">{label}</label>
|
||||
<Input className="text-sm h-8" value={rapportForm[key] || ''} onChange={e => setRapportForm(f => ({ ...f, [key]: e.target.value }))} placeholder="HH:MM" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Lagebild */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Lage bei Eintreffen</label>
|
||||
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
|
||||
</div>
|
||||
{/* Massnahmen (read-only, from journal) */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Massnahmen (aus Journal)</label>
|
||||
<div className="border rounded-md p-2 bg-gray-50 dark:bg-gray-800 text-sm max-h-32 overflow-auto">
|
||||
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
|
||||
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5">• {m}</div>)
|
||||
) : <span className="text-gray-400">Keine Einträge</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Bemerkungen */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Bemerkungen</label>
|
||||
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
|
||||
</div>
|
||||
{/* Unterschriften */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzleiter/in</label>
|
||||
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Rapporteur</label>
|
||||
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRapportDialog(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={creatingRapport}
|
||||
onClick={async () => {
|
||||
if (!projectId) return
|
||||
setCreatingRapport(true)
|
||||
try {
|
||||
// Capture map screenshot — compress to JPEG and resize for smaller payload
|
||||
let mapScreenshot = ''
|
||||
const rawScreenshot = preCapuredScreenshot || ''
|
||||
if (!rawScreenshot) {
|
||||
try {
|
||||
if (mapRef?.current) {
|
||||
const canvas = mapRef.current.getCanvas()
|
||||
if (canvas) {
|
||||
// Resize to max 1600px wide and convert to JPEG
|
||||
const maxW = 1600
|
||||
const ratio = Math.min(1, maxW / canvas.width)
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = Math.round(canvas.width * ratio)
|
||||
offscreen.height = Math.round(canvas.height * ratio)
|
||||
const ctx = offscreen.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
|
||||
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.75)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('Map screenshot failed:', e) }
|
||||
} else if (rawScreenshot.length > 500000) {
|
||||
// Compress pre-captured screenshot if too large
|
||||
try {
|
||||
const img = new Image()
|
||||
img.src = rawScreenshot
|
||||
await new Promise(r => { img.onload = r; img.onerror = r })
|
||||
const maxW = 1600
|
||||
const ratio = Math.min(1, maxW / img.naturalWidth)
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = Math.round(img.naturalWidth * ratio)
|
||||
offscreen.height = Math.round(img.naturalHeight * ratio)
|
||||
const ctx = offscreen.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
|
||||
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.75)
|
||||
}
|
||||
} catch { mapScreenshot = rawScreenshot }
|
||||
} else {
|
||||
mapScreenshot = rawScreenshot
|
||||
}
|
||||
// Convert logo URL to base64 for PDF rendering
|
||||
let logoDataUri = ''
|
||||
if (rapportForm.logoUrl) {
|
||||
try {
|
||||
const logoRes = await fetch(rapportForm.logoUrl)
|
||||
if (logoRes.ok) {
|
||||
const blob = await logoRes.blob()
|
||||
logoDataUri = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
} catch (e) { console.warn('Logo fetch failed:', e) }
|
||||
}
|
||||
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
|
||||
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
|
||||
const res = await fetch('/api/rapports', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId, data: rapportData }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
setLastRapportLink(`/rapport/${result.token}`)
|
||||
setShowRapportDialog(false)
|
||||
window.open(`/rapport/${result.token}`, '_blank')
|
||||
} else {
|
||||
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
console.error('[Rapport] API error:', res.status, errData)
|
||||
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error creating rapport:', err)
|
||||
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
|
||||
} finally {
|
||||
setCreatingRapport(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
|
||||
Rapport generieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showRapportDialog && projectId && (
|
||||
<RapportDialog
|
||||
projectId={projectId}
|
||||
rapportForm={rapportForm}
|
||||
setRapportForm={setRapportForm}
|
||||
mapRef={mapRef}
|
||||
mapScreenshot={preCapuredScreenshot}
|
||||
onClose={() => setShowRapportDialog(false)}
|
||||
onRapportCreated={(link) => setLastRapportLink(link)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
269
src/components/journal/rapport-dialog.tsx
Normal file
269
src/components/journal/rapport-dialog.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { useState, MutableRefObject } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FileText, Loader2 } from 'lucide-react'
|
||||
|
||||
interface RapportDialogProps {
|
||||
projectId: string
|
||||
rapportForm: Record<string, any>
|
||||
setRapportForm: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||
mapRef?: MutableRefObject<any>
|
||||
mapScreenshot?: string
|
||||
onClose: () => void
|
||||
onRapportCreated: (link: string) => void
|
||||
}
|
||||
|
||||
export function RapportDialog({
|
||||
projectId,
|
||||
rapportForm,
|
||||
setRapportForm,
|
||||
mapRef,
|
||||
mapScreenshot: preCapuredScreenshot,
|
||||
onClose,
|
||||
onRapportCreated,
|
||||
}: RapportDialogProps) {
|
||||
const [creatingRapport, setCreatingRapport] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!projectId) return
|
||||
setCreatingRapport(true)
|
||||
try {
|
||||
// Capture map screenshot — compress to JPEG and resize for smaller payload
|
||||
let mapScreenshot = ''
|
||||
const rawScreenshot = preCapuredScreenshot || ''
|
||||
if (!rawScreenshot) {
|
||||
try {
|
||||
if (mapRef?.current) {
|
||||
const canvas = mapRef.current.getCanvas()
|
||||
if (canvas) {
|
||||
// Resize to max 2400px wide and convert to JPEG
|
||||
const maxW = 2400
|
||||
const ratio = Math.min(1, maxW / canvas.width)
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = Math.round(canvas.width * ratio)
|
||||
offscreen.height = Math.round(canvas.height * ratio)
|
||||
const ctx = offscreen.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
|
||||
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('Map screenshot failed:', e) }
|
||||
} else if (rawScreenshot.length > 800000) {
|
||||
// Compress pre-captured screenshot if too large
|
||||
try {
|
||||
const img = new Image()
|
||||
img.src = rawScreenshot
|
||||
await new Promise(r => { img.onload = r; img.onerror = r })
|
||||
const maxW = 2400
|
||||
const ratio = Math.min(1, maxW / img.naturalWidth)
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = Math.round(img.naturalWidth * ratio)
|
||||
offscreen.height = Math.round(img.naturalHeight * ratio)
|
||||
const ctx = offscreen.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
|
||||
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
|
||||
}
|
||||
} catch { mapScreenshot = rawScreenshot }
|
||||
} else {
|
||||
mapScreenshot = rawScreenshot
|
||||
}
|
||||
// Convert logo URL to base64 for PDF rendering
|
||||
let logoDataUri = ''
|
||||
if (rapportForm.logoUrl) {
|
||||
try {
|
||||
const logoRes = await fetch(rapportForm.logoUrl)
|
||||
if (logoRes.ok) {
|
||||
const blob = await logoRes.blob()
|
||||
logoDataUri = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
} catch (e) { console.warn('Logo fetch failed:', e) }
|
||||
}
|
||||
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
|
||||
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
|
||||
const res = await fetch('/api/rapports', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId, data: rapportData }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
onRapportCreated(`/rapport/${result.token}`)
|
||||
onClose()
|
||||
window.open(`/rapport/${result.token}`, '_blank')
|
||||
} else {
|
||||
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
console.error('[Rapport] API error:', res.status, errData)
|
||||
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error creating rapport:', err)
|
||||
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
|
||||
} finally {
|
||||
setCreatingRapport(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
|
||||
<div className="bg-card rounded-lg shadow-2xl w-full max-w-2xl mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Einsatzrapport erstellen
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
|
||||
{/* Organisation */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Organisation</label>
|
||||
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Abteilung</label>
|
||||
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Einsatzdaten */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Datum</label>
|
||||
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Uhrzeit</label>
|
||||
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatz-Nr.</label>
|
||||
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Priorität</label>
|
||||
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Ort */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzort / Adresse</label>
|
||||
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Objekt / Gebäude</label>
|
||||
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Alarmierungsart</label>
|
||||
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Stichwort / Meldebild</label>
|
||||
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
|
||||
</div>
|
||||
{/* Zeitverlauf */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase mb-1 block">Zeitverlauf</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">Alarm</label>
|
||||
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground">Eintreffen</label>
|
||||
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Lagebild */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Lage bei Eintreffen</label>
|
||||
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
|
||||
</div>
|
||||
{/* Massnahmen (read-only, from journal) */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Massnahmen (aus Journal)</label>
|
||||
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
|
||||
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
|
||||
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5">• {m}</div>)
|
||||
) : <span className="text-muted-foreground">Keine Einträge</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* SOMA Checklist (read-only, from journal) */}
|
||||
{Array.isArray(rapportForm.somaItems) && rapportForm.somaItems.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">SOMA Checkliste</label>
|
||||
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
|
||||
{rapportForm.somaItems.map((s: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 py-0.5">
|
||||
<span className={`inline-block w-4 text-center ${s.confirmed ? 'text-blue-600 font-bold' : 'text-muted-foreground'}`}>{s.confirmed ? '✓' : '—'}</span>
|
||||
<span className={`inline-block w-4 text-center ${s.ok ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{s.ok ? '✓' : '—'}</span>
|
||||
<span>{s.label}</span>
|
||||
{s.confirmedAt && <span className="text-[10px] text-muted-foreground ml-auto">{s.confirmedAt}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Pendenzen (read-only, from journal) */}
|
||||
{Array.isArray(rapportForm.pendenzenItems) && rapportForm.pendenzenItems.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Pendenzen</label>
|
||||
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
|
||||
{rapportForm.pendenzenItems.map((p: any, i: number) => (
|
||||
<div key={i} className={`flex items-center gap-2 py-0.5 ${p.done ? 'line-through text-muted-foreground' : ''}`}>
|
||||
<span className={`inline-block w-4 text-center ${p.done ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{p.done ? '✓' : '○'}</span>
|
||||
<span>{p.what}</span>
|
||||
{p.who && <span className="text-muted-foreground text-xs">({p.who})</span>}
|
||||
{p.whenHow && <span className="text-muted-foreground text-xs ml-1">— {p.whenHow}</span>}
|
||||
{p.doneAt && <span className="text-[10px] text-green-600 ml-auto">{p.doneAt}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Bemerkungen */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
|
||||
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
|
||||
</div>
|
||||
{/* Unterschriften */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzleiter/in</label>
|
||||
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase">Rapporteur</label>
|
||||
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Abbrechen</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={creatingRapport}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
|
||||
Rapport generieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Ruler,
|
||||
Eraser,
|
||||
} from 'lucide-react'
|
||||
import type { DrawMode } from '@/app/app/page'
|
||||
import type { DrawMode } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LeftToolbarProps {
|
||||
@@ -48,18 +48,18 @@ const colors = [
|
||||
{ value: '#ffffff', name: 'Weiss' },
|
||||
]
|
||||
|
||||
const drawTools: { mode: DrawMode; icon: typeof MousePointer2; label: string }[] = [
|
||||
{ mode: 'select', icon: MousePointer2, label: 'Auswählen' },
|
||||
{ mode: 'point', icon: CircleDot, label: 'Punkt' },
|
||||
{ mode: 'linestring', icon: Minus, label: 'Linie' },
|
||||
{ mode: 'polygon', icon: Pentagon, label: 'Polygon' },
|
||||
{ mode: 'rectangle', icon: Square, label: 'Rechteck' },
|
||||
{ mode: 'circle', icon: Circle, label: 'Kreis' },
|
||||
{ mode: 'freehand', icon: Pencil, label: 'Freihand' },
|
||||
{ mode: 'arrow', icon: MoveRight, label: 'Pfeil / Route' },
|
||||
{ mode: 'text', icon: Type, label: 'Text' },
|
||||
{ mode: 'eraser', icon: Eraser, label: 'Radiergummi' },
|
||||
{ mode: 'measure', icon: Ruler, label: 'Messen' },
|
||||
const drawTools: { mode: DrawMode; icon: typeof MousePointer2; label: string; shortcut: string }[] = [
|
||||
{ mode: 'select', icon: MousePointer2, label: 'Auswählen', shortcut: 'V' },
|
||||
{ mode: 'point', icon: CircleDot, label: 'Punkt', shortcut: 'P' },
|
||||
{ mode: 'linestring', icon: Minus, label: 'Linie', shortcut: 'L' },
|
||||
{ mode: 'polygon', icon: Pentagon, label: 'Polygon', shortcut: 'G' },
|
||||
{ mode: 'rectangle', icon: Square, label: 'Rechteck', shortcut: 'R' },
|
||||
{ mode: 'circle', icon: Circle, label: 'Kreis', shortcut: 'C' },
|
||||
{ mode: 'freehand', icon: Pencil, label: 'Freihand', shortcut: 'F' },
|
||||
{ mode: 'arrow', icon: MoveRight, label: 'Pfeil / Route', shortcut: 'A' },
|
||||
{ mode: 'text', icon: Type, label: 'Text', shortcut: 'T' },
|
||||
{ mode: 'eraser', icon: Eraser, label: 'Radiergummi', shortcut: 'E' },
|
||||
{ mode: 'measure', icon: Ruler, label: 'Messen', shortcut: 'M' },
|
||||
]
|
||||
|
||||
export function LeftToolbar({
|
||||
@@ -92,7 +92,7 @@ export function LeftToolbar({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{tool.label}</p>
|
||||
<p>{tool.label} <kbd className="ml-1.5 text-[10px] px-1 py-0.5 bg-muted rounded border border-border font-mono">{tool.shortcut}</kbd></p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
||||
async function fetchIcons() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/icons')
|
||||
const res = await fetch('/api/icons', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const allCats: DisplayCategory[] = (data.categories || [])
|
||||
@@ -126,9 +126,26 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
||||
// Separate tenant-specific icons ("Eigene" category) from global library
|
||||
const eigene = allCats.find(c => c.name === 'Eigene')
|
||||
const globalCats = allCats.filter(c => c.name !== 'Eigene')
|
||||
setTenantIcons(eigene?.symbols || [])
|
||||
|
||||
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
|
||||
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
imageUrl: s.url || `/api/icons/${s.id}/image`,
|
||||
}))
|
||||
const legacyOwn = eigene?.symbols || []
|
||||
// Deduplicate: mySymbols takes priority over legacy
|
||||
const mySymbolIds = new Set(mySymbols.map(s => s.id))
|
||||
const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
|
||||
|
||||
setTenantIcons(mergedTenant)
|
||||
setCategories(globalCats)
|
||||
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
|
||||
|
||||
// Auto-collapse library if tenant has own symbols
|
||||
if (mergedTenant.length > 0) {
|
||||
setShowLibrarySection(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load icons:', err)
|
||||
@@ -137,7 +154,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
|
||||
}
|
||||
}
|
||||
fetchIcons()
|
||||
}, [])
|
||||
}, [tenantId])
|
||||
|
||||
const filteredCategories = categories.map((cat) => ({
|
||||
...cat,
|
||||
|
||||
@@ -37,11 +37,11 @@ import {
|
||||
ImagePlus,
|
||||
Key,
|
||||
Shield,
|
||||
Building2,
|
||||
MapPin,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||
import type { Project, DrawFeature } from '@/app/app/page'
|
||||
import type { Project, DrawFeature } from '@/types'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import { Logo } from '@/components/ui/logo'
|
||||
|
||||
@@ -65,6 +65,7 @@ interface TopbarProps {
|
||||
userName?: string
|
||||
userRole?: string
|
||||
onLogout?: () => void
|
||||
onStartTour?: () => void
|
||||
}
|
||||
|
||||
export function Topbar({
|
||||
@@ -87,10 +88,15 @@ export function Topbar({
|
||||
userName,
|
||||
userRole,
|
||||
onLogout,
|
||||
onStartTour,
|
||||
}: TopbarProps) {
|
||||
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
|
||||
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
|
||||
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
|
||||
const [showDeleteAccountDialog, setShowDeleteAccountDialog] = useState(false)
|
||||
const [deleteAccountPw, setDeleteAccountPw] = useState('')
|
||||
const [deleteAccountLoading, setDeleteAccountLoading] = useState(false)
|
||||
const [deleteAccountError, setDeleteAccountError] = useState('')
|
||||
const [pwOld, setPwOld] = useState('')
|
||||
const [pwNew, setPwNew] = useState('')
|
||||
const [pwConfirm, setPwConfirm] = useState('')
|
||||
@@ -155,6 +161,7 @@ export function Topbar({
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
<Button
|
||||
data-tour="save"
|
||||
variant="outline"
|
||||
className="h-9 md:h-10 px-2 md:px-4 text-sm"
|
||||
onClick={onSaveProject}
|
||||
@@ -173,7 +180,7 @@ export function Topbar({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
<DropdownMenuItem onClick={onNewProject} className="py-2.5 px-3">
|
||||
<DropdownMenuItem data-tour="new-project" onClick={onNewProject} className="py-2.5 px-3">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Neuer Einsatz
|
||||
</DropdownMenuItem>
|
||||
@@ -278,18 +285,25 @@ export function Topbar({
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Kennwort ändern
|
||||
</DropdownMenuItem>
|
||||
{userRole === 'TENANT_ADMIN' && (
|
||||
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
|
||||
<Building2 className="w-4 h-4 mr-2" />
|
||||
Organisation
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
|
||||
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Administration
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onStartTour && (
|
||||
<DropdownMenuItem onClick={onStartTour}>
|
||||
<HelpCircle className="w-4 h-4 mr-2" />
|
||||
Tour starten
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => { setShowDeleteAccountDialog(true); setDeleteAccountPw(''); setDeleteAccountError('') }}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Konto löschen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Abmelden
|
||||
@@ -403,6 +417,9 @@ export function Topbar({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{(p as any).owner?.name && (
|
||||
<><span className="font-medium text-foreground/70">{(p as any).owner.name}</span> · </>
|
||||
)}
|
||||
Erstellt: {formatDateTime(p.createdAt)} | Geändert: {formatDateTime(p.updatedAt)}
|
||||
</p>
|
||||
</button>
|
||||
@@ -539,6 +556,81 @@ export function Topbar({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Account Dialog */}
|
||||
<Dialog open={showDeleteAccountDialog} onOpenChange={setShowDeleteAccountDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Konto löschen
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ihr Konto wird unwiderruflich gelöscht. Ihre Projekte und Daten bleiben der Organisation erhalten,
|
||||
aber Ihr persönlicher Zugang wird entfernt.
|
||||
</p>
|
||||
{userRole === 'TENANT_ADMIN' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-950/30 rounded-lg p-3 text-xs text-amber-800 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
|
||||
<strong>Hinweis:</strong> Als einziger Administrator müssen Sie zuerst die Organisation unter Einstellungen löschen oder die Admin-Rolle übertragen.
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Passwort zur Bestätigung</label>
|
||||
<input
|
||||
type="password"
|
||||
value={deleteAccountPw}
|
||||
onChange={(e) => { setDeleteAccountPw(e.target.value); setDeleteAccountError('') }}
|
||||
placeholder="Ihr Passwort"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{deleteAccountError && (
|
||||
<p className="text-sm text-destructive">{deleteAccountError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteAccountDialog(false)}
|
||||
disabled={deleteAccountLoading}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteAccountLoading || !deleteAccountPw}
|
||||
onClick={async () => {
|
||||
setDeleteAccountLoading(true)
|
||||
setDeleteAccountError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/delete-account', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: deleteAccountPw }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
setDeleteAccountError(data.error || 'Löschung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setDeleteAccountError('Verbindungsfehler')
|
||||
} finally {
|
||||
setDeleteAccountLoading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleteAccountLoading ? 'Wird gelöscht...' : 'Konto endgültig löschen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useDrop } from 'react-dnd'
|
||||
import Moveable from 'react-moveable'
|
||||
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
|
||||
import type { Project, DrawFeature, DrawMode } from '@/app/app/page'
|
||||
import type { Project, DrawFeature, DrawMode } from '@/types'
|
||||
|
||||
// Haversine distance between two [lng, lat] points in meters
|
||||
function haversineDistance(a: number[], b: number[]): number {
|
||||
@@ -23,6 +23,19 @@ function formatDistance(meters: number): string {
|
||||
return `${(meters / 1000).toFixed(2)} km`
|
||||
}
|
||||
|
||||
// Approximate polygon area in m² using the Shoelace formula on spherical coordinates
|
||||
function polygonArea(ring: number[][]): number {
|
||||
const toRad = Math.PI / 180
|
||||
const R = 6371000
|
||||
let area = 0
|
||||
const n = ring.length - 1 // exclude closing duplicate
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += (ring[j][0] - ring[i][0]) * toRad * (2 + Math.sin(ring[i][1] * toRad) + Math.sin(ring[j][1] * toRad))
|
||||
}
|
||||
return Math.abs(area * R * R / 2)
|
||||
}
|
||||
|
||||
// Point-to-segment distance in screen pixels (for click detection on lines)
|
||||
function pointToSegmentDist(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
|
||||
const dx = x2 - x1, dy = y2 - y1
|
||||
@@ -93,7 +106,8 @@ export function MapView({
|
||||
const measureMarkersRef = useRef<maplibregl.Marker[]>([])
|
||||
const measureCoordsRef = useRef<number[][]>([])
|
||||
const [isMapLoaded, setIsMapLoaded] = useState(false)
|
||||
const [isSatellite, setIsSatellite] = useState(false)
|
||||
const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm')
|
||||
const [layerDropdownOpen, setLayerDropdownOpen] = useState(false)
|
||||
const [measurePointCount, setMeasurePointCount] = useState(0)
|
||||
const [measureFinished, setMeasureFinished] = useState(false)
|
||||
const [drawingPointCount, setDrawingPointCount] = useState(0)
|
||||
@@ -676,6 +690,15 @@ export function MapView({
|
||||
attribution: '© Esri, Maxar, Earthstar Geographics',
|
||||
maxzoom: 19,
|
||||
},
|
||||
'swisstopo': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© swisstopo',
|
||||
maxzoom: 17,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
@@ -689,6 +712,12 @@ export function MapView({
|
||||
source: 'satellite',
|
||||
layout: { visibility: 'none' },
|
||||
},
|
||||
{
|
||||
id: 'swisstopo',
|
||||
type: 'raster',
|
||||
source: 'swisstopo',
|
||||
layout: { visibility: 'none' },
|
||||
},
|
||||
],
|
||||
},
|
||||
center: [initialCenter.lng, initialCenter.lat],
|
||||
@@ -940,7 +969,7 @@ export function MapView({
|
||||
// Eraser mode: click on/near a feature to delete it
|
||||
if (mode === 'eraser') {
|
||||
const pixel = e.point
|
||||
const tolerance = 10 // px
|
||||
const tolerance = 20 // px
|
||||
const currentFeatures = featuresRef.current
|
||||
let closestIdx = -1
|
||||
let closestDist = Infinity
|
||||
@@ -949,29 +978,44 @@ export function MapView({
|
||||
const f = currentFeatures[i]
|
||||
const geom = f.geometry
|
||||
|
||||
// Get all coordinates to check proximity
|
||||
let allCoords: number[][] = []
|
||||
if (geom.type === 'Point') {
|
||||
allCoords = [geom.coordinates as number[]]
|
||||
} else if (geom.type === 'LineString') {
|
||||
allCoords = geom.coordinates as number[][]
|
||||
} else if (geom.type === 'Polygon') {
|
||||
allCoords = (geom.coordinates as number[][][])[0] || []
|
||||
}
|
||||
|
||||
for (const c of allCoords) {
|
||||
const projected = m.project([c[0], c[1]])
|
||||
const projected = m.project(geom.coordinates as [number, number])
|
||||
const dx = projected.x - pixel.x
|
||||
const dy = projected.y - pixel.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist
|
||||
closestIdx = i
|
||||
if (dist < closestDist) { closestDist = dist; closestIdx = i }
|
||||
} else if (geom.type === 'LineString') {
|
||||
const lineCoords = geom.coordinates as number[][]
|
||||
for (let j = 0; j < lineCoords.length - 1; j++) {
|
||||
const p1 = m.project(lineCoords[j] as [number, number])
|
||||
const p2 = m.project(lineCoords[j + 1] as [number, number])
|
||||
const dist = pointToSegmentDist(pixel.x, pixel.y, p1.x, p1.y, p2.x, p2.y)
|
||||
if (dist < closestDist) { closestDist = dist; closestIdx = i }
|
||||
}
|
||||
} else if (geom.type === 'Polygon') {
|
||||
const ring = (geom.coordinates as number[][][])[0] || []
|
||||
// Check edges
|
||||
for (let j = 0; j < ring.length - 1; j++) {
|
||||
const p1 = m.project(ring[j] as [number, number])
|
||||
const p2 = m.project(ring[j + 1] as [number, number])
|
||||
const dist = pointToSegmentDist(pixel.x, pixel.y, p1.x, p1.y, p2.x, p2.y)
|
||||
if (dist < closestDist) { closestDist = dist; closestIdx = i }
|
||||
}
|
||||
// Point-in-polygon test (screen space)
|
||||
const projected = ring.map(c => m.project(c as [number, number]))
|
||||
let inside = false
|
||||
for (let j = 0, k = projected.length - 1; j < projected.length; k = j++) {
|
||||
const xi = projected[j].x, yi = projected[j].y
|
||||
const xk = projected[k].x, yk = projected[k].y
|
||||
if (((yi > pixel.y) !== (yk > pixel.y)) && (pixel.x < (xk - xi) * (pixel.y - yi) / (yk - yi) + xi)) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
if (inside) { closestDist = 0; closestIdx = i }
|
||||
}
|
||||
}
|
||||
|
||||
if (closestIdx >= 0 && closestDist < tolerance * 3) {
|
||||
if (closestIdx >= 0 && closestDist < tolerance) {
|
||||
const deleted = currentFeatures[closestIdx]
|
||||
const newFeatures = currentFeatures.filter((_, i) => i !== closestIdx)
|
||||
onFeaturesChangeRef.current(newFeatures)
|
||||
@@ -1377,12 +1421,12 @@ export function MapView({
|
||||
const lineCoords = f.geometry.coordinates as number[][]
|
||||
if (lineCoords.length < 2) return
|
||||
|
||||
// Get last two points to calculate arrow direction
|
||||
// Get last two points to calculate arrow direction using screen-projected coords
|
||||
const p1 = lineCoords[lineCoords.length - 2]
|
||||
const p2 = lineCoords[lineCoords.length - 1]
|
||||
const angle = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]) * (180 / Math.PI)
|
||||
// MapLibre uses screen coords where Y is inverted, so negate the angle
|
||||
const screenAngle = -angle + 90
|
||||
const px1 = map.current.project(p1 as [number, number])
|
||||
const px2 = map.current.project(p2 as [number, number])
|
||||
const screenAngle = Math.atan2(px2.y - px1.y, px2.x - px1.x) * (180 / Math.PI) + 90
|
||||
|
||||
const color = (f.properties.color as string) || '#000000'
|
||||
const arrowEl = document.createElement('div')
|
||||
@@ -1396,7 +1440,7 @@ export function MapView({
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center' })
|
||||
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'viewport' })
|
||||
.setLngLat(p2 as [number, number])
|
||||
.addTo(map.current)
|
||||
markersRef.current.push(marker)
|
||||
@@ -1428,21 +1472,27 @@ export function MapView({
|
||||
midpoint = [cx / len, cy / len]
|
||||
}
|
||||
|
||||
// Apply stored label offset if present
|
||||
const labelOffset = f.properties.labelOffset as [number, number] | undefined
|
||||
if (labelOffset) {
|
||||
midpoint = [midpoint[0] + labelOffset[0], midpoint[1] + labelOffset[1]]
|
||||
}
|
||||
|
||||
const el = document.createElement('div')
|
||||
const isDanger = f.type === 'dangerzone'
|
||||
el.style.cssText = `
|
||||
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'};
|
||||
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.82)'};
|
||||
color: #fff;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
pointer-events: ${canEdit ? 'auto' : 'none'};
|
||||
letter-spacing: 0.3px;
|
||||
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
|
||||
cursor: ${canEdit ? 'pointer' : 'default'};
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||
cursor: ${canEdit ? 'grab' : 'default'};
|
||||
transform: translate(0,0);
|
||||
will-change: transform;
|
||||
`
|
||||
@@ -1459,16 +1509,30 @@ export function MapView({
|
||||
|
||||
const labelLine = document.createElement('div')
|
||||
labelLine.textContent = label
|
||||
labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;'
|
||||
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
|
||||
|
||||
const infoLine = document.createElement('div')
|
||||
infoLine.textContent = `${lenText} / ${hoseCount} Schl.`
|
||||
infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;'
|
||||
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
|
||||
|
||||
el.appendChild(labelLine)
|
||||
el.appendChild(infoLine)
|
||||
} else {
|
||||
el.textContent = label
|
||||
// Polygon: show label + area
|
||||
const ring = (f.geometry.coordinates as number[][][])[0]
|
||||
const area = polygonArea(ring)
|
||||
const areaText = area < 10000 ? `${Math.round(area)} m²` : `${(area / 10000).toFixed(2)} ha`
|
||||
|
||||
const labelLine = document.createElement('div')
|
||||
labelLine.textContent = label
|
||||
labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
|
||||
|
||||
const infoLine = document.createElement('div')
|
||||
infoLine.textContent = areaText
|
||||
infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
|
||||
|
||||
el.appendChild(labelLine)
|
||||
el.appendChild(infoLine)
|
||||
}
|
||||
|
||||
// Double-click to edit label — only in select mode
|
||||
@@ -1481,9 +1545,41 @@ export function MapView({
|
||||
})
|
||||
}
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
||||
const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'viewport' })
|
||||
.setLngLat(midpoint)
|
||||
.addTo(map.current)
|
||||
|
||||
// Save label position offset on drag end
|
||||
if (canEdit) {
|
||||
marker.on('dragend', () => {
|
||||
const newPos = marker.getLngLat()
|
||||
// Calculate midpoint without offset to get the base midpoint
|
||||
let baseMid: [number, number]
|
||||
const feat = featuresRef.current.find(feat => feat.id === f.id)
|
||||
if (!feat) return
|
||||
if (feat.geometry.type === 'LineString') {
|
||||
const coords = feat.geometry.coordinates as number[][]
|
||||
const midIdx = Math.floor(coords.length / 2)
|
||||
if (coords.length === 2) {
|
||||
baseMid = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
|
||||
} else {
|
||||
baseMid = coords[midIdx] as [number, number]
|
||||
}
|
||||
} else {
|
||||
const ring = (feat.geometry.coordinates as number[][][])[0]
|
||||
const len = ring.length - 1
|
||||
let cx = 0, cy = 0
|
||||
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
|
||||
baseMid = [cx / len, cy / len]
|
||||
}
|
||||
const offset: [number, number] = [newPos.lng - baseMid[0], newPos.lat - baseMid[1]]
|
||||
const updated = featuresRef.current.map(pf =>
|
||||
pf.id === f.id ? { ...pf, properties: { ...pf.properties, labelOffset: offset } } : pf
|
||||
)
|
||||
onFeaturesChangeRef.current(updated)
|
||||
})
|
||||
}
|
||||
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
|
||||
@@ -1568,7 +1664,7 @@ export function MapView({
|
||||
}
|
||||
|
||||
try {
|
||||
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center' })
|
||||
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' })
|
||||
.setLngLat(coords)
|
||||
.addTo(map.current)
|
||||
|
||||
@@ -1634,7 +1730,7 @@ export function MapView({
|
||||
el.textContent = (f.properties.text as string) || ''
|
||||
wrapper.appendChild(el)
|
||||
|
||||
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center' })
|
||||
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' })
|
||||
.setLngLat(coords)
|
||||
.addTo(map.current)
|
||||
|
||||
@@ -1796,9 +1892,19 @@ export function MapView({
|
||||
}
|
||||
}, [drawMode, deselectSymbol])
|
||||
|
||||
// ESC to cancel drawing / finalize measurement
|
||||
// ESC to cancel drawing, DEL to delete selected symbol
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// DEL / Backspace → delete selected symbol
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
|
||||
if (selectedSymbolRef.current) {
|
||||
e.preventDefault()
|
||||
deleteSelectedSymbol()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
// In measure mode: finalize (keep line + labels), just stop adding
|
||||
if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) {
|
||||
@@ -1825,7 +1931,7 @@ export function MapView({
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
}, [deleteSelectedSymbol])
|
||||
|
||||
// Drop zone for symbols — use stable ref connection (no inline ref callback)
|
||||
const [, drop] = useDrop(() => ({
|
||||
@@ -2034,11 +2140,12 @@ export function MapView({
|
||||
selectedSymbolRef.current.scale = Math.max(0.2, Math.min(10, startScale * ratio))
|
||||
selectedSymbolRef.current.innerEl.style.fontSize = `${baseFontSize * selectedSymbolRef.current.scale}px`
|
||||
} else {
|
||||
// For symbols: resize wrapper
|
||||
// For symbols: resize wrapper, use ratio from start to preserve zoom-aware scale
|
||||
selectedSymbolRef.current.wrapperEl.style.width = `${width}px`
|
||||
selectedSymbolRef.current.wrapperEl.style.height = `${height}px`
|
||||
const baseSize = 32
|
||||
selectedSymbolRef.current.scale = Math.max(0.1, Math.min(10, width / baseSize))
|
||||
const startW = selectedSymbolRef.current.resizeStartWidth || 1
|
||||
const startScale = selectedSymbolRef.current.resizeStartScale || 1
|
||||
selectedSymbolRef.current.scale = Math.max(0.1, Math.min(10, startScale * (width / startW)))
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -2082,29 +2189,52 @@ export function MapView({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Layer toggle: OSM / Satellite */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!map.current) return
|
||||
const newSat = !isSatellite
|
||||
setIsSatellite(newSat)
|
||||
map.current.setLayoutProperty('osm', 'visibility', newSat ? 'none' : 'visible')
|
||||
map.current.setLayoutProperty('satellite', 'visibility', newSat ? 'visible' : 'none')
|
||||
}}
|
||||
className="absolute top-3 right-3 z-10 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold shadow-lg border transition-colors"
|
||||
style={{
|
||||
background: isSatellite ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.95)',
|
||||
color: isSatellite ? '#fff' : '#333',
|
||||
borderColor: isSatellite ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.15)',
|
||||
}}
|
||||
title={isSatellite ? 'Zur Kartenansicht wechseln' : 'Zur Satellitenansicht wechseln'}
|
||||
>
|
||||
{isSatellite ? (
|
||||
<><svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3z"/><path d="M9 4v13"/><path d="M15 7v13"/></svg>Karte</>
|
||||
) : (
|
||||
<><svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>Satellit</>
|
||||
{/* Layer selector dropdown */}
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<button
|
||||
onClick={() => setLayerDropdownOpen(v => !v)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-medium shadow-md border backdrop-blur-sm transition-all"
|
||||
style={{
|
||||
background: activeBaseLayer !== 'osm' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.92)',
|
||||
color: activeBaseLayer !== 'osm' ? '#fff' : '#1f2937',
|
||||
borderColor: activeBaseLayer !== 'osm' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
{{ osm: 'OpenStreetMap', satellite: 'Satellit', swisstopo: 'Swisstopo' }[activeBaseLayer]}
|
||||
<svg className={`w-3 h-3 opacity-50 transition-transform ${layerDropdownOpen ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
{layerDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setLayerDropdownOpen(false)} />
|
||||
<div className="absolute right-0 mt-1 z-20 min-w-[160px] rounded-lg shadow-xl border overflow-hidden backdrop-blur-md"
|
||||
style={{ background: 'rgba(255,255,255,0.95)', borderColor: 'rgba(0,0,0,0.08)' }}>
|
||||
{([
|
||||
{ key: 'osm', label: 'OpenStreetMap' },
|
||||
{ key: 'satellite', label: 'Satellit (Esri)' },
|
||||
{ key: 'swisstopo', label: 'Swisstopo Karte' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
if (!map.current) return
|
||||
const allLayers: Array<'osm' | 'satellite' | 'swisstopo'> = ['osm', 'satellite', 'swisstopo']
|
||||
for (const l of allLayers) {
|
||||
map.current.setLayoutProperty(l, 'visibility', l === key ? 'visible' : 'none')
|
||||
}
|
||||
setActiveBaseLayer(key)
|
||||
setLayerDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-[11px] font-medium transition-colors ${activeBaseLayer === key ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
{activeBaseLayer === key && <span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-2 align-middle" />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zeichnung abschliessen Button (Linie/Polygon/Pfeil) */}
|
||||
{(drawMode === 'linestring' || drawMode === 'polygon' || drawMode === 'arrow' || drawMode === 'dangerzone') && drawingPointCount >= 2 && (
|
||||
|
||||
318
src/components/onboarding/onboarding-tour.tsx
Normal file
318
src/components/onboarding/onboarding-tour.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
X, ChevronRight, ChevronLeft, SkipForward,
|
||||
MapPin, Pencil, LayoutGrid, Save, Ruler, Users, Keyboard, Rocket,
|
||||
MousePointer2, CircleDot, Minus, Pentagon, Square, Circle, MoveRight, Type, Eraser,
|
||||
Lock, ClipboardList, Download, AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const TOUR_STORAGE_KEY = 'lageplan-onboarding-completed'
|
||||
|
||||
interface TourStep {
|
||||
title: string
|
||||
description: string
|
||||
icon?: React.ReactNode
|
||||
targetSelector?: string
|
||||
position?: 'top' | 'bottom' | 'left' | 'right'
|
||||
tools?: { icon: React.ReactNode; label: string; shortcut?: string }[]
|
||||
}
|
||||
|
||||
const TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
title: 'Willkommen bei Lageplan!',
|
||||
icon: <Rocket className="w-5 h-5 text-red-500" />,
|
||||
description: 'Lageplan ist deine taktische Lageskizzen-App für den Feuerwehr-Einsatz. Diese kurze Tour zeigt dir die wichtigsten Funktionen.',
|
||||
},
|
||||
{
|
||||
title: 'Einsatz erstellen',
|
||||
icon: <MapPin className="w-5 h-5 text-red-500" />,
|
||||
description: 'Erstelle über «Neuer Einsatz» ein Projekt. Gib eine Adresse ein — die Karte fliegt automatisch dorthin.',
|
||||
targetSelector: '[data-tour="new-project"]',
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
title: 'Bearbeitung starten',
|
||||
icon: <Lock className="w-5 h-5 text-green-500" />,
|
||||
description: 'Klicke auf «Bearbeitung starten» um die Karte zu bearbeiten. Nur ein Benutzer gleichzeitig kann bearbeiten — andere sehen deine Änderungen live.',
|
||||
targetSelector: '[data-tour="edit-toggle"]',
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
title: 'Zeichenwerkzeuge',
|
||||
icon: <Pencil className="w-5 h-5 text-blue-500" />,
|
||||
description: 'Links findest du alle Werkzeuge. Jedes hat ein Tastenkürzel:',
|
||||
targetSelector: '[data-tour="toolbar"]',
|
||||
position: 'right',
|
||||
tools: [
|
||||
{ icon: <MousePointer2 className="w-3.5 h-3.5" />, label: 'Auswählen', shortcut: 'V' },
|
||||
{ icon: <CircleDot className="w-3.5 h-3.5" />, label: 'Punkt', shortcut: 'P' },
|
||||
{ icon: <Minus className="w-3.5 h-3.5" />, label: 'Linie / Schlauch', shortcut: 'L' },
|
||||
{ icon: <Pentagon className="w-3.5 h-3.5" />, label: 'Polygon', shortcut: 'G' },
|
||||
{ icon: <Square className="w-3.5 h-3.5" />, label: 'Rechteck', shortcut: 'R' },
|
||||
{ icon: <Circle className="w-3.5 h-3.5" />, label: 'Kreis', shortcut: 'C' },
|
||||
{ icon: <Pencil className="w-3.5 h-3.5" />, label: 'Freihand', shortcut: 'F' },
|
||||
{ icon: <MoveRight className="w-3.5 h-3.5" />, label: 'Pfeil / Route', shortcut: 'A' },
|
||||
{ icon: <Type className="w-3.5 h-3.5" />, label: 'Text', shortcut: 'T' },
|
||||
{ icon: <Eraser className="w-3.5 h-3.5" />, label: 'Radiergummi', shortcut: 'E' },
|
||||
{ icon: <Ruler className="w-3.5 h-3.5" />, label: 'Messen', shortcut: 'M' },
|
||||
{ icon: <AlertTriangle className="w-3.5 h-3.5" />, label: 'Gefahrenzone', shortcut: 'D' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Symbole (Drag & Drop)',
|
||||
icon: <LayoutGrid className="w-5 h-5 text-orange-500" />,
|
||||
description: 'Rechts findest du taktische Feuerwehr-Symbole. Deine eigenen Symbole stehen zuoberst. Ziehe Symbole per Drag & Drop auf die Karte. Klicke auf ein platziertes Symbol um es zu drehen, skalieren oder löschen.',
|
||||
targetSelector: '[data-tour="sidebar"]',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
title: 'Journal & Rapport',
|
||||
icon: <ClipboardList className="w-5 h-5 text-indigo-500" />,
|
||||
description: 'Wechsle rechts zum Journal-Tab für das Einsatz-Journal. Erfasse Einträge, SOMA-Checkliste und Pendenzen. Erstelle einen druckfertigen Einsatzrapport als PDF.',
|
||||
targetSelector: '[data-tour="sidebar"]',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
title: 'Speichern & Export',
|
||||
icon: <Save className="w-5 h-5 text-green-500" />,
|
||||
description: 'Speichere mit dem Speichern-Button oder Ctrl+S. Exportiere die Karte als PNG oder PDF über das Menü.',
|
||||
targetSelector: '[data-tour="save"]',
|
||||
position: 'bottom',
|
||||
tools: [
|
||||
{ icon: <Save className="w-3.5 h-3.5" />, label: 'Speichern', shortcut: 'Ctrl+S' },
|
||||
{ icon: <Download className="w-3.5 h-3.5" />, label: 'Export als PNG/PDF' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Live-Zusammenarbeit',
|
||||
icon: <Users className="w-5 h-5 text-cyan-500" />,
|
||||
description: 'Mehrere Benutzer arbeiten gleichzeitig am selben Einsatz. Änderungen werden in Echtzeit synchronisiert.',
|
||||
},
|
||||
{
|
||||
title: 'Tastenkürzel',
|
||||
icon: <Keyboard className="w-5 h-5 text-slate-500" />,
|
||||
description: 'Wichtige Kürzel:',
|
||||
tools: [
|
||||
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Z</span>, label: 'Rückgängig' },
|
||||
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Y</span>, label: 'Wiederholen' },
|
||||
{ icon: <span className="text-[10px] font-mono font-bold">Del</span>, label: 'Symbol löschen' },
|
||||
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+S</span>, label: 'Speichern' },
|
||||
{ icon: <span className="text-[10px] font-mono font-bold">?</span>, label: 'Alle Kürzel anzeigen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Bereit für den Einsatz!',
|
||||
icon: <Rocket className="w-5 h-5 text-red-500" />,
|
||||
description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts) erneut starten. Viel Erfolg!',
|
||||
},
|
||||
]
|
||||
|
||||
interface OnboardingTourProps {
|
||||
forceShow?: boolean
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTourProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [highlightRect, setHighlightRect] = useState<DOMRect | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (forceShow) {
|
||||
setIsVisible(true)
|
||||
setCurrentStep(0)
|
||||
return
|
||||
}
|
||||
const completed = localStorage.getItem(TOUR_STORAGE_KEY)
|
||||
if (!completed) {
|
||||
// Small delay so the app renders first
|
||||
const timer = setTimeout(() => setIsVisible(true), 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [forceShow])
|
||||
|
||||
const updateHighlight = useCallback(() => {
|
||||
const step = TOUR_STEPS[currentStep]
|
||||
if (step.targetSelector) {
|
||||
const el = document.querySelector(step.targetSelector)
|
||||
if (el) {
|
||||
setHighlightRect(el.getBoundingClientRect())
|
||||
return
|
||||
}
|
||||
}
|
||||
setHighlightRect(null)
|
||||
}, [currentStep])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
updateHighlight()
|
||||
window.addEventListener('resize', updateHighlight)
|
||||
return () => window.removeEventListener('resize', updateHighlight)
|
||||
}, [isVisible, currentStep, updateHighlight])
|
||||
|
||||
const completeTour = useCallback(() => {
|
||||
localStorage.setItem(TOUR_STORAGE_KEY, 'true')
|
||||
setIsVisible(false)
|
||||
onComplete?.()
|
||||
}, [onComplete])
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < TOUR_STEPS.length - 1) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
} else {
|
||||
completeTour()
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 0) setCurrentStep(currentStep - 1)
|
||||
}
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
const step = TOUR_STEPS[currentStep]
|
||||
const isFirst = currentStep === 0
|
||||
const isLast = currentStep === TOUR_STEPS.length - 1
|
||||
|
||||
// Calculate tooltip position based on highlight
|
||||
const getTooltipStyle = (): React.CSSProperties => {
|
||||
if (!highlightRect) {
|
||||
// Center on screen
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
}
|
||||
const pos = step.position || 'bottom'
|
||||
const gap = 12
|
||||
switch (pos) {
|
||||
case 'bottom':
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: highlightRect.bottom + gap,
|
||||
left: Math.max(16, Math.min(highlightRect.left, window.innerWidth - 360)),
|
||||
}
|
||||
case 'top':
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: window.innerHeight - highlightRect.top + gap,
|
||||
left: Math.max(16, Math.min(highlightRect.left, window.innerWidth - 360)),
|
||||
}
|
||||
case 'right':
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: Math.max(16, highlightRect.top),
|
||||
left: highlightRect.right + gap,
|
||||
}
|
||||
case 'left':
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: Math.max(16, highlightRect.top),
|
||||
right: window.innerWidth - highlightRect.left + gap,
|
||||
}
|
||||
default:
|
||||
return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay */}
|
||||
<div
|
||||
className="fixed inset-0 z-[99998]"
|
||||
style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={completeTour}
|
||||
/>
|
||||
|
||||
{/* Highlight cutout */}
|
||||
{highlightRect && (
|
||||
<div
|
||||
className="fixed z-[99999] pointer-events-none rounded-lg"
|
||||
style={{
|
||||
top: highlightRect.top - 4,
|
||||
left: highlightRect.left - 4,
|
||||
width: highlightRect.width + 8,
|
||||
height: highlightRect.height + 8,
|
||||
boxShadow: '0 0 0 9999px rgba(0,0,0,0.5)',
|
||||
border: '2px solid rgba(59,130,246,0.7)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
className="z-[100000] w-[340px] bg-card border border-border rounded-xl shadow-2xl p-5"
|
||||
style={getTooltipStyle()}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{step.icon}
|
||||
<h3 className="font-semibold text-base">{step.title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={completeTour}
|
||||
className="text-muted-foreground hover:text-foreground -mt-1 -mr-1 p-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
|
||||
{step.description}
|
||||
</p>
|
||||
{step.tools && (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mb-3 text-xs">
|
||||
{step.tools.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 py-0.5">
|
||||
<span className="text-muted-foreground shrink-0">{t.icon}</span>
|
||||
<span className="truncate">{t.label}</span>
|
||||
{t.shortcut && <kbd className="ml-auto shrink-0 px-1 py-0.5 bg-muted rounded border border-border font-mono text-[9px]">{t.shortcut}</kbd>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{TOUR_STEPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-colors ${
|
||||
i === currentStep ? 'bg-primary' : i < currentStep ? 'bg-primary/40' : 'bg-muted-foreground/20'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isFirst && (
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={prevStep}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isFirst && (
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs text-muted-foreground" onClick={completeTour}>
|
||||
<SkipForward className="w-3 h-3 mr-1" />
|
||||
Überspringen
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8 px-3" onClick={nextStep}>
|
||||
{isLast ? 'Fertig' : 'Weiter'}
|
||||
{!isLast && <ChevronRight className="w-4 h-4 ml-0.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Reset the onboarding tour so it shows again next time */
|
||||
export function resetOnboardingTour() {
|
||||
localStorage.removeItem(TOUR_STORAGE_KEY)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface User {
|
||||
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
|
||||
tenantId?: string
|
||||
tenantSlug?: string
|
||||
emailVerified?: boolean
|
||||
}
|
||||
|
||||
export interface TenantInfo {
|
||||
@@ -28,7 +29,7 @@ interface AuthContextType {
|
||||
user: User | null
|
||||
tenant: TenantInfo | null
|
||||
loading: boolean
|
||||
login: (email: string, password: string) => Promise<{ success: boolean; error?: string }>
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string }>
|
||||
logout: () => Promise<void>
|
||||
canEdit: () => boolean
|
||||
isAdmin: () => boolean
|
||||
@@ -61,12 +62,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const login = async (email: string, password: string, rememberMe = false) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, rememberMe }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
101
src/hooks/use-auto-save.ts
Normal file
101
src/hooks/use-auto-save.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { DrawFeature, Project } from '@/types'
|
||||
import { addToSyncQueue, getSyncQueue } from '@/lib/offline-sync'
|
||||
|
||||
interface UseAutoSaveOptions {
|
||||
currentProject: Project | null
|
||||
features: DrawFeature[]
|
||||
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||
mapRef: React.MutableRefObject<any>
|
||||
socketRef: React.MutableRefObject<any>
|
||||
isEditingByMe: boolean
|
||||
setSyncQueueCount: (count: number) => void
|
||||
}
|
||||
|
||||
export function useAutoSave({
|
||||
currentProject,
|
||||
features,
|
||||
featuresRef,
|
||||
mapRef,
|
||||
socketRef,
|
||||
isEditingByMe,
|
||||
setSyncQueueCount,
|
||||
}: UseAutoSaveOptions) {
|
||||
// Persist features to localStorage on change (including empty array to reflect deletions)
|
||||
useEffect(() => {
|
||||
localStorage.setItem('lageplan-features', JSON.stringify(features))
|
||||
}, [features])
|
||||
|
||||
// Auto-save to API — debounced 2s after every feature change + fallback interval
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const saveFeaturesToApi = useCallback(async () => {
|
||||
if (!currentProject?.id) return
|
||||
const url = `/api/projects/${currentProject.id}/features`
|
||||
const mapInstance = mapRef.current
|
||||
const body: any = { features: featuresRef.current }
|
||||
if (mapInstance) {
|
||||
const c = mapInstance.getCenter()
|
||||
body.mapCenter = { lng: c.lng, lat: c.lat }
|
||||
body.mapZoom = mapInstance.getZoom()
|
||||
}
|
||||
|
||||
// If offline, queue the save for later sync
|
||||
if (!navigator.onLine) {
|
||||
addToSyncQueue(url, 'PUT', body)
|
||||
setSyncQueueCount(getSyncQueue().length)
|
||||
console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
console.log('[Auto-Save] Features gespeichert')
|
||||
socketRef.current?.emit('features-updated', {
|
||||
projectId: currentProject.id,
|
||||
features: featuresRef.current,
|
||||
})
|
||||
} else if (res.status === 404) {
|
||||
console.warn('[Auto-Save] Projekt nicht in DB')
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error — queue for later
|
||||
addToSyncQueue(url, 'PUT', body)
|
||||
setSyncQueueCount(getSyncQueue().length)
|
||||
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
|
||||
}
|
||||
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount])
|
||||
|
||||
// Debounced save on every feature change (2s delay)
|
||||
useEffect(() => {
|
||||
if (!currentProject || !isEditingByMe) return
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [features, currentProject, isEditingByMe, saveFeaturesToApi])
|
||||
|
||||
// Also save on page unload / tab switch
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (currentProject?.id && featuresRef.current.length > 0) {
|
||||
const payload = JSON.stringify({ features: featuresRef.current })
|
||||
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
|
||||
}
|
||||
}
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
|
||||
saveFeaturesToApi()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [currentProject, isEditingByMe, saveFeaturesToApi, featuresRef])
|
||||
}
|
||||
74
src/hooks/use-keyboard-shortcuts.ts
Normal file
74
src/hooks/use-keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import type { DrawMode, DrawFeature } from '@/types'
|
||||
|
||||
interface UseKeyboardShortcutsOptions {
|
||||
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onSave: () => void
|
||||
onDelete: (newFeatures: DrawFeature[]) => void
|
||||
onToolChange: (mode: DrawMode) => void
|
||||
onHelpOpen: () => void
|
||||
}
|
||||
|
||||
const TOOL_SHORTCUTS: Record<string, DrawMode> = {
|
||||
'v': 'select', 's': 'select',
|
||||
'p': 'point',
|
||||
'l': 'linestring',
|
||||
'g': 'polygon',
|
||||
'r': 'rectangle',
|
||||
'c': 'circle',
|
||||
'f': 'freehand',
|
||||
'a': 'arrow',
|
||||
't': 'text',
|
||||
'e': 'eraser',
|
||||
'm': 'measure',
|
||||
'd': 'dangerzone',
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
featuresRef,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave,
|
||||
onDelete,
|
||||
onToolChange,
|
||||
onHelpOpen,
|
||||
}: UseKeyboardShortcutsOptions) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore when typing in inputs/textareas
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
|
||||
|
||||
// ? or F1 → help
|
||||
if (e.key === '?' || e.key === 'F1') { e.preventDefault(); onHelpOpen(); return }
|
||||
|
||||
// DEL / Backspace → delete selected feature(s)
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
const current = featuresRef.current
|
||||
const selected = current.filter(f => f.properties?._selected)
|
||||
if (selected.length > 0) {
|
||||
onDelete(current.filter(f => !f.properties?._selected))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd shortcuts
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); onRedo(); return }
|
||||
if (e.key === 'z') { e.preventDefault(); onUndo(); return }
|
||||
if (e.key === 'y') { e.preventDefault(); onRedo(); return }
|
||||
if (e.key === 's') { e.preventDefault(); onSave(); return }
|
||||
return
|
||||
}
|
||||
|
||||
// Tool shortcuts (single key, no modifier)
|
||||
const mode = TOOL_SHORTCUTS[e.key.toLowerCase()]
|
||||
if (mode) { e.preventDefault(); onToolChange(mode); return }
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [featuresRef, onUndo, onRedo, onSave, onDelete, onToolChange, onHelpOpen])
|
||||
}
|
||||
329
src/hooks/use-map-export.ts
Normal file
329
src/hooks/use-map-export.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useCallback } from 'react'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import type { DrawFeature, Project } from '@/types'
|
||||
|
||||
interface UseMapExportOptions {
|
||||
mapRef: React.MutableRefObject<any>
|
||||
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||
currentProject: Project | null
|
||||
tenant: { id: string; name: string } | null
|
||||
addAudit: (action: string) => void
|
||||
toast: (opts: { title: string; description?: string; variant?: string }) => void
|
||||
}
|
||||
|
||||
export function useMapExport({
|
||||
mapRef,
|
||||
featuresRef,
|
||||
currentProject,
|
||||
tenant,
|
||||
addAudit,
|
||||
toast,
|
||||
}: UseMapExportOptions) {
|
||||
const handleExport = useCallback(async (format: 'png' | 'pdf') => {
|
||||
const mapInstance = mapRef.current
|
||||
if (!mapInstance) {
|
||||
toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get the MapLibre canvas (tiles + vector drawings)
|
||||
const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
|
||||
const w = mapCanvas.width
|
||||
const h = mapCanvas.height
|
||||
|
||||
// 2. Create composite canvas
|
||||
const exportCanvas = document.createElement('canvas')
|
||||
exportCanvas.width = w
|
||||
exportCanvas.height = h
|
||||
const ctx = exportCanvas.getContext('2d')!
|
||||
ctx.drawImage(mapCanvas, 0, 0)
|
||||
|
||||
// 3. Draw symbols manually at correct size/rotation
|
||||
const currentFeatures = featuresRef.current
|
||||
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
|
||||
const container = mapInstance.getContainer()
|
||||
const dpr = mapCanvas.width / container.offsetWidth
|
||||
const zoom = mapInstance.getZoom()
|
||||
// Symbol sizing: match the map rendering logic exactly
|
||||
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
|
||||
const currentZoom = zoom
|
||||
|
||||
// Helper: load image as promise
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = reject
|
||||
img.src = src
|
||||
})
|
||||
|
||||
// Draw symbol features
|
||||
for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
|
||||
if (f.geometry.type !== 'Point') continue
|
||||
const coords = f.geometry.coordinates as [number, number]
|
||||
const pixel = mapInstance.project(coords)
|
||||
const px = pixel.x * dpr
|
||||
const py = pixel.y * dpr
|
||||
|
||||
const scale = (f.properties.scale as number) || 1
|
||||
const rotation = (f.properties.rotation as number) || 0
|
||||
const baseSize = 32
|
||||
const placementZoom = (f.properties.placementZoom as number) || 17
|
||||
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
|
||||
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
|
||||
|
||||
// Determine image source
|
||||
const iconId = f.properties.iconId as string
|
||||
const imageUrl = f.properties.imageUrl as string
|
||||
let imgSrc = imageUrl || ''
|
||||
if (!imgSrc && iconId) {
|
||||
const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
|
||||
const sym = getSymbolById(iconId)
|
||||
if (sym) imgSrc = getSymbolDataUri(sym)
|
||||
}
|
||||
|
||||
if (imgSrc) {
|
||||
try {
|
||||
const img = await loadImage(imgSrc)
|
||||
// Replicate CSS background-size: contain (preserve aspect ratio)
|
||||
const imgAspect = img.naturalWidth / img.naturalHeight
|
||||
let drawW = size
|
||||
let drawH = size
|
||||
if (imgAspect > 1) {
|
||||
drawH = size / imgAspect
|
||||
} else {
|
||||
drawW = size * imgAspect
|
||||
}
|
||||
ctx.save()
|
||||
ctx.translate(px, py)
|
||||
ctx.rotate((rotation * Math.PI) / 180)
|
||||
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
|
||||
ctx.restore()
|
||||
} catch (e) {
|
||||
console.warn('[Export] Failed to load symbol image:', iconId, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw arrowheads for arrow features
|
||||
for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
|
||||
if (f.geometry.type !== 'LineString') continue
|
||||
const lineCoords = f.geometry.coordinates as number[][]
|
||||
if (lineCoords.length < 2) continue
|
||||
const p1 = lineCoords[lineCoords.length - 2]
|
||||
const p2 = lineCoords[lineCoords.length - 1]
|
||||
const px1 = mapInstance.project(p1 as [number, number])
|
||||
const px2 = mapInstance.project(p2 as [number, number])
|
||||
const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
|
||||
const color = (f.properties.color as string) || '#000000'
|
||||
const arrowSize = 14 * dpr
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(px2.x * dpr, px2.y * dpr)
|
||||
ctx.rotate(angle + Math.PI / 2)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -arrowSize)
|
||||
ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
|
||||
ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Draw line/polygon label markers at midpoints
|
||||
for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
|
||||
const label = f.properties.label as string
|
||||
let midpoint: [number, number]
|
||||
|
||||
if (f.geometry.type === 'LineString') {
|
||||
const coords = f.geometry.coordinates as number[][]
|
||||
const midIdx = Math.floor(coords.length / 2)
|
||||
if (coords.length === 2) {
|
||||
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
|
||||
} else {
|
||||
midpoint = coords[midIdx] as [number, number]
|
||||
}
|
||||
} else {
|
||||
// Polygon: centroid of first ring
|
||||
const ring = (f.geometry.coordinates as number[][][])[0]
|
||||
const len = ring.length - 1
|
||||
let cx = 0, cy = 0
|
||||
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
|
||||
midpoint = [cx / len, cy / len]
|
||||
}
|
||||
|
||||
const pixel = mapInstance.project(midpoint)
|
||||
const px = pixel.x * dpr
|
||||
const py = pixel.y * dpr
|
||||
const fontSize = 13 * dpr
|
||||
const isDanger = f.type === 'dangerzone'
|
||||
const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
|
||||
const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
|
||||
|
||||
ctx.save()
|
||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
const metrics = ctx.measureText(label)
|
||||
const padX = 7 * dpr
|
||||
const padY = 3 * dpr
|
||||
const boxW = metrics.width + padX * 2
|
||||
const boxH = fontSize + padY * 2
|
||||
const radius = 4 * dpr
|
||||
|
||||
// Background pill
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
||||
ctx.fill()
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = borderColor
|
||||
ctx.lineWidth = 1.5 * dpr
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
|
||||
ctx.stroke()
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillText(label, px, py)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Draw text features
|
||||
for (const f of currentFeatures.filter(f => f.type === 'text')) {
|
||||
if (f.geometry.type !== 'Point') continue
|
||||
const coords = f.geometry.coordinates as [number, number]
|
||||
const pixel = mapInstance.project(coords)
|
||||
const px = pixel.x * dpr
|
||||
const py = pixel.y * dpr
|
||||
|
||||
const text = (f.properties.text as string) || ''
|
||||
const fontSize = ((f.properties.fontSize as number) || 14) * dpr
|
||||
const color = (f.properties.color as string) || '#000000'
|
||||
|
||||
ctx.save()
|
||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
// White outline
|
||||
ctx.strokeStyle = '#ffffff'
|
||||
ctx.lineWidth = 3 * dpr
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.strokeText(text, px, py)
|
||||
// Fill
|
||||
ctx.fillStyle = color
|
||||
ctx.fillText(text, px, py)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const title = currentProject?.title || 'Lageplan'
|
||||
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
|
||||
|
||||
if (format === 'png') {
|
||||
const link = document.createElement('a')
|
||||
link.download = `${safeName}.png`
|
||||
link.href = exportCanvas.toDataURL('image/png')
|
||||
link.click()
|
||||
addAudit(`Export: ${safeName}.png`)
|
||||
toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
|
||||
} else {
|
||||
// PDF Export — rapport-style clean layout
|
||||
const imgData = exportCanvas.toDataURL('image/png')
|
||||
const now = new Date()
|
||||
const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
const locationStr = currentProject?.location || ''
|
||||
const einsatzNr = (currentProject as any)?.einsatzNr || ''
|
||||
const tenantLabel = tenant?.name || ''
|
||||
|
||||
// A4 landscape (mm)
|
||||
const pdf = new jsPDF('l', 'mm', 'a4')
|
||||
const pageW = pdf.internal.pageSize.getWidth() // 297
|
||||
const pageH = pdf.internal.pageSize.getHeight() // 210
|
||||
const m = 10 // margin
|
||||
|
||||
// ── Header section ──
|
||||
const headerY = m
|
||||
pdf.setFontSize(18)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.setTextColor(26, 26, 26)
|
||||
pdf.text('Einsatz-Lageplan', m, headerY + 6)
|
||||
|
||||
pdf.setFontSize(9)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setTextColor(107, 114, 128) // gray-500
|
||||
pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
|
||||
|
||||
// Right side: Einsatz-Nr + date
|
||||
pdf.setFontSize(14)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.setTextColor(185, 28, 28) // red-700
|
||||
if (einsatzNr) {
|
||||
const nrW = pdf.getTextWidth(einsatzNr)
|
||||
pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
|
||||
}
|
||||
pdf.setFontSize(9)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setTextColor(107, 114, 128)
|
||||
const dateLabel = `${dateStr} · ${timeStr}`
|
||||
const dlW = pdf.getTextWidth(dateLabel)
|
||||
pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
|
||||
|
||||
// Divider line + red accent
|
||||
const divY = headerY + 15
|
||||
pdf.setDrawColor(26, 26, 26)
|
||||
pdf.setLineWidth(0.8)
|
||||
pdf.line(m, divY, pageW - m, divY)
|
||||
pdf.setFillColor(185, 28, 28)
|
||||
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
|
||||
|
||||
// ── Map image ──
|
||||
const mapTop = divY + 3
|
||||
const mapBottom = pageH - m - 12 // leave space for footer
|
||||
const mapAreaW = pageW - 2 * m
|
||||
const mapAreaH = mapBottom - mapTop
|
||||
|
||||
// Fit map image into area while preserving aspect ratio
|
||||
const imgAspect = w / h
|
||||
const areaAspect = mapAreaW / mapAreaH
|
||||
let drawW = mapAreaW
|
||||
let drawH = mapAreaH
|
||||
if (imgAspect > areaAspect) {
|
||||
drawH = mapAreaW / imgAspect
|
||||
} else {
|
||||
drawW = mapAreaH * imgAspect
|
||||
}
|
||||
const mapX = m + (mapAreaW - drawW) / 2
|
||||
const mapY = mapTop + (mapAreaH - drawH) / 2
|
||||
|
||||
// Light border around map
|
||||
pdf.setDrawColor(229, 231, 235)
|
||||
pdf.setLineWidth(0.3)
|
||||
pdf.rect(mapX, mapY, drawW, drawH)
|
||||
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
|
||||
|
||||
// ── Footer ──
|
||||
const footerY = pageH - m - 4
|
||||
pdf.setFontSize(7)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setTextColor(156, 163, 175) // gray-400
|
||||
pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
|
||||
const footerR = 'app.lageplan.ch'
|
||||
const frW = pdf.getTextWidth(footerR)
|
||||
pdf.text(footerR, pageW - m - frW, footerY)
|
||||
|
||||
pdf.save(`${safeName}.pdf`)
|
||||
addAudit(`Export: ${safeName}.pdf`)
|
||||
toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error)
|
||||
toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
|
||||
}
|
||||
}, [currentProject, tenant, toast, addAudit, mapRef, featuresRef])
|
||||
|
||||
return { handleExport }
|
||||
}
|
||||
57
src/hooks/use-offline-sync.ts
Normal file
57
src/hooks/use-offline-sync.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
|
||||
|
||||
interface UseOfflineSyncOptions {
|
||||
toast: (opts: { title: string; description?: string; variant?: string }) => void
|
||||
}
|
||||
|
||||
export function useOfflineSync({ toast }: UseOfflineSyncOptions) {
|
||||
const [isOffline, setIsOffline] = useState(false)
|
||||
const [syncQueueCount, setSyncQueueCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setIsOffline(!checkOnline())
|
||||
setSyncQueueCount(getSyncQueue().length)
|
||||
|
||||
const goOffline = () => {
|
||||
setIsOffline(true)
|
||||
toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
|
||||
}
|
||||
const goOnline = async () => {
|
||||
setIsOffline(false)
|
||||
const queue = getSyncQueue()
|
||||
if (queue.length > 0) {
|
||||
toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
|
||||
const result = await flushSyncQueue()
|
||||
setSyncQueueCount(getSyncQueue().length)
|
||||
if (result.success > 0) {
|
||||
toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
|
||||
}
|
||||
if (result.failed > 0) {
|
||||
toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
|
||||
}
|
||||
} else {
|
||||
toast({ title: 'Wieder online' })
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('offline', goOffline)
|
||||
window.addEventListener('online', goOnline)
|
||||
|
||||
// Listen for SW sync messages
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
|
||||
flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('offline', goOffline)
|
||||
window.removeEventListener('online', goOnline)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
return { isOffline, syncQueueCount, setSyncQueueCount }
|
||||
}
|
||||
268
src/hooks/use-realtime-sync.ts
Normal file
268
src/hooks/use-realtime-sync.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { getSocket, setSocketRoom } from '@/lib/socket'
|
||||
import type { DrawFeature, Project } from '@/types'
|
||||
|
||||
interface UseRealtimeSyncOptions {
|
||||
currentProject: Project | null
|
||||
user: { id: string; name: string; role: string } | null
|
||||
featuresRef: React.MutableRefObject<DrawFeature[]>
|
||||
setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void
|
||||
toast: (opts: { title: string; description?: string; variant?: string }) => void
|
||||
}
|
||||
|
||||
export function useRealtimeSync({
|
||||
currentProject,
|
||||
user,
|
||||
featuresRef,
|
||||
setFeatures,
|
||||
toast,
|
||||
}: UseRealtimeSyncOptions) {
|
||||
// Live editing lock state
|
||||
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
|
||||
const [isEditingByMe, setIsEditingByMe] = useState(false)
|
||||
const [editingLoading, setEditingLoading] = useState(false)
|
||||
|
||||
// Unique session ID per browser tab (survives re-renders, not page reload)
|
||||
const sessionIdRef = useRef<string>('')
|
||||
if (!sessionIdRef.current) {
|
||||
sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
// ─── Editing Lock: Check status + Heartbeat + Polling ─────────
|
||||
|
||||
const checkEditingStatus = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
if (data.editing) {
|
||||
setEditingBy(data.editingBy)
|
||||
setIsEditingByMe(data.isMe)
|
||||
} else {
|
||||
setEditingBy(null)
|
||||
setIsEditingByMe(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Editing] Status check failed:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check editing status when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.id) {
|
||||
setEditingBy(null)
|
||||
setIsEditingByMe(false)
|
||||
return
|
||||
}
|
||||
checkEditingStatus(currentProject.id)
|
||||
}, [currentProject?.id, checkEditingStatus])
|
||||
|
||||
// Heartbeat: keep lock alive every 30s while I'm editing
|
||||
useEffect(() => {
|
||||
if (!currentProject?.id || !isEditingByMe) return
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }),
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[Heartbeat] Failed:', e)
|
||||
}
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [currentProject?.id, isEditingByMe])
|
||||
|
||||
// Socket.io: real-time sync for features, editing status, journal
|
||||
const socketRef = useRef<any>(null)
|
||||
const prevProjectIdRef = useRef<string | null>(null)
|
||||
|
||||
// Throttled socket broadcast for near-real-time sync
|
||||
const lastEmitRef = useRef(0)
|
||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const currentProjectRef = useRef(currentProject)
|
||||
useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
|
||||
|
||||
const isEditingByMeRef = useRef(false)
|
||||
useEffect(() => { isEditingByMeRef.current = isEditingByMe }, [isEditingByMe])
|
||||
|
||||
const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
|
||||
const proj = currentProjectRef.current
|
||||
if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
|
||||
const now = Date.now()
|
||||
const emit = () => {
|
||||
socketRef.current?.emit('features-updated', {
|
||||
projectId: proj!.id,
|
||||
features: feats,
|
||||
})
|
||||
lastEmitRef.current = Date.now()
|
||||
}
|
||||
// Throttle: emit at most every 800ms for snappier sync
|
||||
if (now - lastEmitRef.current > 800) {
|
||||
emit()
|
||||
} else {
|
||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
|
||||
emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProject?.id) return
|
||||
|
||||
const socket = getSocket()
|
||||
socketRef.current = socket
|
||||
|
||||
// Leave old room, join new room
|
||||
if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) {
|
||||
socket.emit('leave-project', prevProjectIdRef.current)
|
||||
}
|
||||
socket.emit('join-project', currentProject.id)
|
||||
setSocketRoom(currentProject.id)
|
||||
prevProjectIdRef.current = currentProject.id
|
||||
|
||||
// Listen for features changes from other clients (only apply if NOT the editor)
|
||||
const onFeaturesChanged = (data: { features: any[] }) => {
|
||||
// Skip if I'm the one editing — my local state is the source of truth
|
||||
if (isEditingByMeRef.current) {
|
||||
console.log('[Socket.io] Ignoring features-changed (I am the editor)')
|
||||
return
|
||||
}
|
||||
if (data.features && Array.isArray(data.features)) {
|
||||
console.log('[Socket.io] Features updated from another client')
|
||||
setFeatures(data.features)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for editing status changes from other clients
|
||||
const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => {
|
||||
if (data.sessionId === sessionIdRef.current) return // ignore own events
|
||||
if (data.editing && data.editingBy) {
|
||||
setEditingBy(data.editingBy)
|
||||
setIsEditingByMe(false)
|
||||
} else {
|
||||
setEditingBy(null)
|
||||
setIsEditingByMe(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for journal changes — trigger a re-fetch in JournalView
|
||||
const onJournalChanged = () => {
|
||||
console.log('[Socket.io] Journal updated from another client')
|
||||
window.dispatchEvent(new CustomEvent('journal-refresh'))
|
||||
}
|
||||
|
||||
socket.on('features-changed', onFeaturesChanged)
|
||||
socket.on('editing-status', onEditingStatus)
|
||||
socket.on('journal-changed', onJournalChanged)
|
||||
|
||||
return () => {
|
||||
socket.off('features-changed', onFeaturesChanged)
|
||||
socket.off('editing-status', onEditingStatus)
|
||||
socket.off('journal-changed', onJournalChanged)
|
||||
}
|
||||
}, [currentProject?.id, setFeatures])
|
||||
|
||||
// Fallback: check editing status on initial load and every 30s
|
||||
useEffect(() => {
|
||||
if (!currentProject?.id) return
|
||||
checkEditingStatus(currentProject.id)
|
||||
const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [currentProject?.id, checkEditingStatus])
|
||||
|
||||
// Release lock on unmount / page close
|
||||
useEffect(() => {
|
||||
const release = () => {
|
||||
if (currentProject?.id && isEditingByMe) {
|
||||
const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' })
|
||||
navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob)
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', release)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', release)
|
||||
release()
|
||||
}
|
||||
}, [currentProject?.id, isEditingByMe])
|
||||
|
||||
const handleStartEditing = useCallback(async () => {
|
||||
if (!currentProject?.id) return
|
||||
setEditingLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setIsEditingByMe(true)
|
||||
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
|
||||
setEditingBy(editingInfo)
|
||||
// Notify other clients
|
||||
socketRef.current?.emit('editing-changed', {
|
||||
projectId: currentProject.id,
|
||||
editing: true,
|
||||
editingBy: editingInfo,
|
||||
sessionId: sessionIdRef.current,
|
||||
})
|
||||
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
|
||||
} catch (e) {
|
||||
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
|
||||
} finally {
|
||||
setEditingLoading(false)
|
||||
}
|
||||
}, [currentProject?.id, user, toast])
|
||||
|
||||
const handleStopEditing = useCallback(async () => {
|
||||
if (!currentProject?.id) return
|
||||
setEditingLoading(true)
|
||||
try {
|
||||
// Save features before releasing lock
|
||||
const currentFeatures = featuresRef.current
|
||||
await fetch(`/api/projects/${currentProject.id}/features`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ features: currentFeatures }),
|
||||
})
|
||||
// Release lock
|
||||
await fetch(`/api/projects/${currentProject.id}/editing`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }),
|
||||
})
|
||||
setIsEditingByMe(false)
|
||||
setEditingBy(null)
|
||||
// Notify other clients: editing stopped + send final features
|
||||
socketRef.current?.emit('editing-changed', {
|
||||
projectId: currentProject.id,
|
||||
editing: false,
|
||||
editingBy: null,
|
||||
sessionId: sessionIdRef.current,
|
||||
})
|
||||
socketRef.current?.emit('features-updated', {
|
||||
projectId: currentProject.id,
|
||||
features: currentFeatures,
|
||||
})
|
||||
toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' })
|
||||
} catch (e) {
|
||||
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' })
|
||||
} finally {
|
||||
setEditingLoading(false)
|
||||
}
|
||||
}, [currentProject?.id, toast, featuresRef])
|
||||
|
||||
return {
|
||||
editingBy,
|
||||
isEditingByMe,
|
||||
editingLoading,
|
||||
socketRef,
|
||||
broadcastFeatures,
|
||||
handleStartEditing,
|
||||
handleStopEditing,
|
||||
}
|
||||
}
|
||||
83
src/lib/api.ts
Normal file
83
src/lib/api.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Standardized API fetch wrapper with consistent error handling.
|
||||
*
|
||||
* Usage:
|
||||
* const data = await apiFetch<{ projects: Project[] }>('/api/projects')
|
||||
* const result = await apiFetch('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ ... }) })
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
data: any
|
||||
|
||||
constructor(message: string, status: number, data?: any) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiFetchOptions extends RequestInit {
|
||||
/** If true, don't throw on non-2xx responses — return null instead */
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed fetch wrapper that:
|
||||
* - Automatically sets Content-Type for JSON bodies
|
||||
* - Parses JSON responses
|
||||
* - Throws ApiError with status code and server error message on failure
|
||||
* - Supports silent mode for optional/non-critical requests
|
||||
*/
|
||||
export async function apiFetch<T = any>(
|
||||
url: string,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<T> {
|
||||
const { silent, ...fetchOptions } = options
|
||||
|
||||
// Auto-set Content-Type for JSON string bodies
|
||||
if (
|
||||
fetchOptions.body &&
|
||||
typeof fetchOptions.body === 'string' &&
|
||||
!fetchOptions.headers
|
||||
) {
|
||||
fetchOptions.headers = { 'Content-Type': 'application/json' }
|
||||
} else if (
|
||||
fetchOptions.body &&
|
||||
typeof fetchOptions.body === 'string' &&
|
||||
fetchOptions.headers &&
|
||||
!(fetchOptions.headers as Record<string, string>)['Content-Type']
|
||||
) {
|
||||
fetchOptions.headers = {
|
||||
...fetchOptions.headers,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, fetchOptions)
|
||||
|
||||
if (!res.ok) {
|
||||
if (silent) return null as T
|
||||
|
||||
let errorData: any = null
|
||||
let errorMessage = `HTTP ${res.status}`
|
||||
try {
|
||||
errorData = await res.json()
|
||||
errorMessage = errorData?.error || errorData?.message || errorMessage
|
||||
} catch {
|
||||
// Response not JSON, use status text
|
||||
errorMessage = res.statusText || errorMessage
|
||||
}
|
||||
throw new ApiError(errorMessage, res.status, errorData)
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) return null as T
|
||||
|
||||
try {
|
||||
return await res.json()
|
||||
} catch {
|
||||
return null as T
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,14 @@ export interface UserPayload {
|
||||
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
|
||||
tenantId?: string
|
||||
tenantSlug?: string
|
||||
emailVerified?: boolean
|
||||
}
|
||||
|
||||
export async function createToken(user: UserPayload): Promise<string> {
|
||||
export async function createToken(user: UserPayload, rememberMe = false): Promise<string> {
|
||||
return await new SignJWT({ user })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('24h')
|
||||
.setExpirationTime(rememberMe ? '30d' : '24h')
|
||||
.sign(JWT_SECRET)
|
||||
}
|
||||
|
||||
@@ -63,18 +64,16 @@ export async function login(
|
||||
}) as any)
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Benutzer nicht gefunden' }
|
||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||
if (!isValidPassword) {
|
||||
return { success: false, error: 'Ungültiges Passwort' }
|
||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||
}
|
||||
|
||||
// Check email verification (skip for SERVER_ADMIN and users created before verification was added)
|
||||
if ((user as any).emailVerified === false && (user.role as string) !== 'SERVER_ADMIN') {
|
||||
return { success: false, error: 'Bitte bestätigen Sie zuerst Ihre E-Mail-Adresse. Prüfen Sie Ihren Posteingang.' }
|
||||
}
|
||||
// Track email verification status (allow login regardless)
|
||||
const emailVerified = (user as any).emailVerified !== false
|
||||
|
||||
// Get first tenant membership for non-server-admins
|
||||
let tenantId: string | undefined
|
||||
@@ -102,6 +101,7 @@ export async function login(
|
||||
role: (user.role === 'ADMIN' ? 'SERVER_ADMIN' : user.role) as UserPayload['role'],
|
||||
tenantId,
|
||||
tenantSlug,
|
||||
emailVerified,
|
||||
}
|
||||
|
||||
return { success: true, user: userPayload }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import html2canvas from 'html2canvas'
|
||||
import jsPDF from 'jspdf'
|
||||
import type { Project, DrawFeature } from '@/app/app/page'
|
||||
import type { Project, DrawFeature } from '@/types'
|
||||
import { formatDateTime } from './utils'
|
||||
|
||||
export interface ExportOptions {
|
||||
|
||||
97
src/lib/offline-sync.ts
Normal file
97
src/lib/offline-sync.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Offline detection and sync queue for saving changes when reconnecting
|
||||
|
||||
const SYNC_QUEUE_KEY = 'lageplan-sync-queue'
|
||||
|
||||
interface SyncQueueItem {
|
||||
id: string
|
||||
url: string
|
||||
method: string
|
||||
body: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Get all queued saves */
|
||||
export function getSyncQueue(): SyncQueueItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SYNC_QUEUE_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a save operation to the sync queue (called when offline) */
|
||||
export function addToSyncQueue(url: string, method: string, body: any): void {
|
||||
const queue = getSyncQueue()
|
||||
// Deduplicate: if same URL+method exists, replace it with newer data
|
||||
const existing = queue.findIndex(q => q.url === url && q.method === method)
|
||||
const item: SyncQueueItem = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
url,
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
if (existing >= 0) {
|
||||
queue[existing] = item
|
||||
} else {
|
||||
queue.push(item)
|
||||
}
|
||||
localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(queue))
|
||||
}
|
||||
|
||||
/** Flush the sync queue — send all queued requests to the server */
|
||||
export async function flushSyncQueue(): Promise<{ success: number; failed: number }> {
|
||||
const queue = getSyncQueue()
|
||||
if (queue.length === 0) return { success: 0, failed: 0 }
|
||||
|
||||
let success = 0
|
||||
let failed = 0
|
||||
const remaining: SyncQueueItem[] = []
|
||||
|
||||
for (const item of queue) {
|
||||
try {
|
||||
const res = await fetch(item.url, {
|
||||
method: item.method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: item.body,
|
||||
})
|
||||
if (res.ok) {
|
||||
success++
|
||||
} else {
|
||||
// Server error — keep in queue for retry
|
||||
remaining.push(item)
|
||||
failed++
|
||||
}
|
||||
} catch {
|
||||
// Still offline — keep in queue
|
||||
remaining.push(item)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(remaining))
|
||||
return { success, failed }
|
||||
}
|
||||
|
||||
/** Clear the sync queue */
|
||||
export function clearSyncQueue(): void {
|
||||
localStorage.removeItem(SYNC_QUEUE_KEY)
|
||||
}
|
||||
|
||||
/** Check if we're online */
|
||||
export function isOnline(): boolean {
|
||||
return navigator.onLine
|
||||
}
|
||||
|
||||
/** Register Background Sync (if supported) */
|
||||
export async function registerBackgroundSync(): Promise<void> {
|
||||
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
await (reg as any).sync.register('sync-saves')
|
||||
} catch {
|
||||
// Background Sync not supported or failed — will use manual flush
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/
|
||||
// Register default font (Helvetica is built-in)
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: '12mm 15mm 15mm',
|
||||
padding: '12mm 15mm 32mm',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
@@ -166,8 +166,7 @@ export function RapportDocument({ data }: { data: RapportData }) {
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Einsatzort / Adresse" value={data.einsatzort} width="50%" />
|
||||
<FieldCell label="Koordinaten" value={data.koordinaten} mono width="25%" />
|
||||
<FieldCell label="Objekt / Gebäude" value={data.objekt} width="25%" />
|
||||
<FieldCell label="Objekt / Gebäude" value={data.objekt} width="50%" />
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Alarmierungsart" value={data.alarmierungsart} width="50%" />
|
||||
@@ -185,16 +184,8 @@ export function RapportDocument({ data }: { data: RapportData }) {
|
||||
</View>
|
||||
<View style={styles.fieldGrid}>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Alarmierung" value={data.zeitAlarm} mono highlight width="25%" />
|
||||
<FieldCell label="Ausrücken" value={data.zeitAusruecken} mono highlight width="25%" />
|
||||
<FieldCell label="Eintreffen" value={data.zeitEintreffen} mono highlight width="25%" />
|
||||
<FieldCell label="Einsatzbereit" value={data.zeitBereit} mono highlight width="25%" />
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<FieldCell label="Feuer unter Kontrolle" value={data.zeitKontrolle} mono highlight width="25%" />
|
||||
<FieldCell label="Feuer aus" value={data.zeitAus} mono highlight width="25%" />
|
||||
<FieldCell label="Einrücken" value={data.zeitEinruecken} mono highlight width="25%" />
|
||||
<FieldCell label="Einsatzende" value={data.zeitEnde} mono highlight width="25%" />
|
||||
<FieldCell label="Alarmierung" value={data.zeitAlarm} mono highlight width="50%" />
|
||||
<FieldCell label="Eintreffen" value={data.zeitEintreffen} mono highlight width="50%" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
111
src/lib/rate-limit.ts
Normal file
111
src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// In-memory rate limiter for API endpoints
|
||||
// Tracks request counts per IP within sliding windows
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const stores = new Map<string, Map<string, RateLimitEntry>>()
|
||||
|
||||
interface RateLimitConfig {
|
||||
/** Unique identifier for this limiter (e.g. 'login', 'register') */
|
||||
id: string
|
||||
/** Maximum requests allowed within the window */
|
||||
max: number
|
||||
/** Window duration in seconds */
|
||||
windowSeconds: number
|
||||
}
|
||||
|
||||
interface RateLimitResult {
|
||||
success: boolean
|
||||
remaining: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
function getStore(id: string): Map<string, RateLimitEntry> {
|
||||
if (!stores.has(id)) {
|
||||
stores.set(id, new Map())
|
||||
}
|
||||
return stores.get(id)!
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired entries (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [, store] of stores) {
|
||||
for (const [key, entry] of store) {
|
||||
if (now > entry.resetAt) {
|
||||
store.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
export function rateLimit(config: RateLimitConfig) {
|
||||
const store = getStore(config.id)
|
||||
|
||||
return {
|
||||
check(ip: string): RateLimitResult {
|
||||
const now = Date.now()
|
||||
const key = ip
|
||||
const entry = store.get(key)
|
||||
|
||||
// No entry or expired → fresh window
|
||||
if (!entry || now > entry.resetAt) {
|
||||
store.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + config.windowSeconds * 1000,
|
||||
})
|
||||
return { success: true, remaining: config.max - 1, resetAt: now + config.windowSeconds * 1000 }
|
||||
}
|
||||
|
||||
// Within window
|
||||
entry.count++
|
||||
if (entry.count > config.max) {
|
||||
return { success: false, remaining: 0, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
return { success: true, remaining: config.max - entry.count, resetAt: entry.resetAt }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-configured limiters for different endpoints
|
||||
export const loginLimiter = rateLimit({ id: 'login', max: 10, windowSeconds: 60 * 5 }) // 10 attempts per 5 min
|
||||
export const registerLimiter = rateLimit({ id: 'register', max: 3, windowSeconds: 60 * 60 }) // 3 per hour
|
||||
export const forgotPasswordLimiter = rateLimit({ id: 'forgot-pw', max: 3, windowSeconds: 60 * 15 }) // 3 per 15 min
|
||||
export const resendVerificationLimiter = rateLimit({ id: 'resend-verify', max: 3, windowSeconds: 60 * 15 })
|
||||
export const contactLimiter = rateLimit({ id: 'contact', max: 5, windowSeconds: 60 * 60 }) // 5 per hour
|
||||
export const deleteAccountLimiter = rateLimit({ id: 'delete-acct', max: 3, windowSeconds: 60 * 15 })
|
||||
export const resetPasswordLimiter = rateLimit({ id: 'reset-pw', max: 5, windowSeconds: 60 * 15 })
|
||||
|
||||
/** Extract client IP from request headers */
|
||||
export function getClientIp(req: Request): string {
|
||||
const forwarded = req.headers.get('x-forwarded-for')
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0].trim()
|
||||
}
|
||||
const realIp = req.headers.get('x-real-ip')
|
||||
if (realIp) return realIp
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
/** Helper: create a 429 response with retry-after header */
|
||||
export function rateLimitResponse(resetAt: number) {
|
||||
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000)
|
||||
const minutes = Math.ceil(retryAfter / 60)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Zu viele Versuche. Bitte warten Sie ${minutes > 1 ? `${minutes} Minuten` : `${retryAfter} Sekunden`} und versuchen es erneut.`,
|
||||
retryAfter,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(retryAfter),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3,23 +3,79 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
let socket: Socket | null = null
|
||||
let currentRoom: string | null = null
|
||||
|
||||
export type SocketStatus = 'connected' | 'disconnected' | 'reconnecting'
|
||||
type StatusListener = (status: SocketStatus) => void
|
||||
|
||||
let currentStatus: SocketStatus = 'disconnected'
|
||||
const statusListeners = new Set<StatusListener>()
|
||||
|
||||
function setStatus(status: SocketStatus) {
|
||||
if (status === currentStatus) return
|
||||
currentStatus = status
|
||||
statusListeners.forEach(fn => fn(status))
|
||||
}
|
||||
|
||||
/** Subscribe to socket connection status changes. Returns an unsubscribe function. */
|
||||
export function onSocketStatus(listener: StatusListener): () => void {
|
||||
statusListeners.add(listener)
|
||||
// Immediately notify current status
|
||||
listener(currentStatus)
|
||||
return () => { statusListeners.delete(listener) }
|
||||
}
|
||||
|
||||
/** Get current socket connection status */
|
||||
export function getSocketStatus(): SocketStatus {
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io({
|
||||
path: '/socket.io',
|
||||
transports: ['polling', 'websocket'],
|
||||
transports: ['websocket', 'polling'],
|
||||
upgrade: true,
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 2000,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 10000,
|
||||
forceNew: false,
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
console.log('[Socket.io] Connected:', socket?.id)
|
||||
setStatus('connected')
|
||||
// Re-join project room after reconnect
|
||||
if (currentRoom) {
|
||||
console.log('[Socket.io] Re-joining room:', currentRoom)
|
||||
socket?.emit('join-project', currentRoom)
|
||||
}
|
||||
})
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.warn('[Socket.io] Disconnected:', reason)
|
||||
setStatus('disconnected')
|
||||
if (reason === 'io server disconnect') {
|
||||
// Server disconnected us, need to manually reconnect
|
||||
socket?.connect()
|
||||
}
|
||||
})
|
||||
socket.on('connect_error', (err) => {
|
||||
console.warn('[Socket.io] Connection error:', err.message)
|
||||
})
|
||||
socket.io.on('reconnect', (attempt) => {
|
||||
console.log('[Socket.io] Reconnected after', attempt, 'attempts')
|
||||
setStatus('connected')
|
||||
})
|
||||
socket.io.on('reconnect_attempt', (attempt) => {
|
||||
console.log('[Socket.io] Reconnect attempt', attempt)
|
||||
setStatus('reconnecting')
|
||||
})
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
/** Track which room the socket should be in (for auto-rejoin on reconnect) */
|
||||
export function setSocketRoom(projectId: string | null): void {
|
||||
currentRoom = projectId
|
||||
}
|
||||
|
||||
114
src/middleware.ts
Normal file
114
src/middleware.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { jwtVerify } from 'jose'
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.NEXTAUTH_SECRET || 'dev-only-fallback-do-not-use-in-production'
|
||||
)
|
||||
|
||||
// Routes that require authentication
|
||||
const PROTECTED_ROUTES = ['/app', '/settings', '/admin']
|
||||
|
||||
// Routes that should redirect to /app if already logged in
|
||||
const AUTH_ROUTES = ['/login', '/register']
|
||||
|
||||
// API routes that are public (no auth needed)
|
||||
const PUBLIC_API_PREFIXES = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/verify-email',
|
||||
'/api/auth/resend-verification',
|
||||
'/api/auth/logout',
|
||||
'/api/contact',
|
||||
'/api/demo',
|
||||
'/api/donate',
|
||||
'/api/rapports/',
|
||||
'/api/tenants/by-slug/',
|
||||
]
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl
|
||||
const token = req.cookies.get('auth-token')?.value
|
||||
|
||||
// Verify token if present
|
||||
let user: any = null
|
||||
if (token) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET)
|
||||
user = payload.user
|
||||
} catch {
|
||||
// Invalid/expired token — clear it
|
||||
const response = NextResponse.redirect(new URL('/login', req.url))
|
||||
response.cookies.delete('auth-token')
|
||||
// Only redirect if accessing protected routes
|
||||
if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) {
|
||||
return response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes: redirect to login if not authenticated
|
||||
if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) {
|
||||
if (!user) {
|
||||
const loginUrl = new URL('/login', req.url)
|
||||
loginUrl.searchParams.set('redirect', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// Admin routes: only SERVER_ADMIN and TENANT_ADMIN
|
||||
if (pathname.startsWith('/admin') && user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
|
||||
return NextResponse.redirect(new URL('/app', req.url))
|
||||
}
|
||||
}
|
||||
|
||||
// Auth routes: redirect to /app if already logged in
|
||||
if (AUTH_ROUTES.some(r => pathname.startsWith(r))) {
|
||||
if (user) {
|
||||
return NextResponse.redirect(new URL('/app', req.url))
|
||||
}
|
||||
}
|
||||
|
||||
// API routes: check auth for non-public endpoints
|
||||
if (pathname.startsWith('/api/') && !PUBLIC_API_PREFIXES.some(p => pathname.startsWith(p))) {
|
||||
if (!user) {
|
||||
// Allow /api/auth/me to return null (used for auth check)
|
||||
if (pathname === '/api/auth/me') {
|
||||
return NextResponse.next()
|
||||
}
|
||||
// Allow /api/icons GET (public for symbol loading)
|
||||
if (pathname === '/api/icons' && req.method === 'GET') {
|
||||
return NextResponse.next()
|
||||
}
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
// Security: block common attack paths
|
||||
if (
|
||||
pathname.includes('..') ||
|
||||
pathname.includes('.env') ||
|
||||
pathname.includes('wp-admin') ||
|
||||
pathname.includes('wp-login') ||
|
||||
pathname.includes('.php') ||
|
||||
pathname.includes('xmlrpc') ||
|
||||
pathname.match(/\.(sql|bak|config|log|ini)$/i)
|
||||
) {
|
||||
return new NextResponse(null, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization)
|
||||
* - favicon.ico, sitemap.xml, robots.txt
|
||||
* - public files (images, sw.js, etc.)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|icons/|sw.js|manifest.json|opengraph-image).*)',
|
||||
],
|
||||
}
|
||||
69
src/stores/project-store.ts
Normal file
69
src/stores/project-store.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Project, Feature, JournalEntry } from '@/types'
|
||||
|
||||
interface ProjectStore {
|
||||
// Projekt-Daten
|
||||
project: Project | null
|
||||
features: Feature[] // Karten-Elemente (Symbole, Linien, Polygone)
|
||||
journalEntries: JournalEntry[]
|
||||
|
||||
// Actions
|
||||
setProject: (project: Project | null) => void
|
||||
setFeatures: (features: Feature[]) => void
|
||||
addFeature: (feature: Feature) => void
|
||||
updateFeature: (id: string, updates: Partial<Feature>) => void
|
||||
removeFeature: (id: string) => void
|
||||
|
||||
setJournalEntries: (entries: JournalEntry[]) => void
|
||||
addJournalEntry: (entry: JournalEntry) => void
|
||||
|
||||
// Realtime-Sync Actions (werden von Socket.io getriggert)
|
||||
syncFeatures: (features: Feature[]) => void
|
||||
syncJournalEntry: (entry: JournalEntry) => void
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set) => ({
|
||||
project: null,
|
||||
features: [],
|
||||
journalEntries: [],
|
||||
|
||||
setProject: (project) => set({ project }),
|
||||
|
||||
setFeatures: (features) => set({ features }),
|
||||
|
||||
addFeature: (feature) => set((state) => ({
|
||||
features: [...state.features, feature]
|
||||
})),
|
||||
|
||||
updateFeature: (id, updates) => set((state) => ({
|
||||
features: state.features.map(f =>
|
||||
f.id === id || f.properties?.id === id
|
||||
? { ...f, properties: { ...f.properties, ...updates } }
|
||||
: f
|
||||
)
|
||||
})),
|
||||
|
||||
removeFeature: (id) => set((state) => ({
|
||||
features: state.features.filter(f => f.id !== id && f.properties?.id !== id)
|
||||
})),
|
||||
|
||||
setJournalEntries: (entries) => set({ journalEntries: entries }),
|
||||
|
||||
addJournalEntry: (entry) => set((state) => ({
|
||||
journalEntries: [...state.journalEntries, entry]
|
||||
})),
|
||||
|
||||
syncFeatures: (features) => set({ features }),
|
||||
|
||||
syncJournalEntry: (entry) => set((state) => {
|
||||
// Avoid duplicates
|
||||
if (state.journalEntries.some(e => e.id === entry.id)) {
|
||||
return {
|
||||
journalEntries: state.journalEntries.map(e => e.id === entry.id ? entry : e)
|
||||
}
|
||||
}
|
||||
return {
|
||||
journalEntries: [...state.journalEntries, entry]
|
||||
}
|
||||
}),
|
||||
}))
|
||||
39
src/stores/tool-store.ts
Normal file
39
src/stores/tool-store.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand'
|
||||
import type { DrawMode } from '@/types'
|
||||
|
||||
export type LineType = 'solid' | 'dashed' | 'dotted'
|
||||
|
||||
interface ToolStore {
|
||||
activeTool: DrawMode | null
|
||||
activeColor: string
|
||||
lineType: LineType
|
||||
lineWidth: number
|
||||
selectedFeatureId: string | null
|
||||
|
||||
// Actions
|
||||
setActiveTool: (tool: ToolStore['activeTool']) => void
|
||||
setActiveColor: (color: string) => void
|
||||
setLineType: (type: ToolStore['lineType']) => void
|
||||
setLineWidth: (width: number) => void
|
||||
selectFeature: (id: string | null) => void
|
||||
resetTool: () => void
|
||||
}
|
||||
|
||||
export const useToolStore = create<ToolStore>((set) => ({
|
||||
activeTool: 'select',
|
||||
activeColor: '#ff0000', // Default Rot
|
||||
lineType: 'solid',
|
||||
lineWidth: 3,
|
||||
selectedFeatureId: null,
|
||||
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
setActiveColor: (color) => set({ activeColor: color }),
|
||||
setLineType: (type) => set({ lineType: type }),
|
||||
setLineWidth: (width) => set({ lineWidth: width }),
|
||||
selectFeature: (id) => set({ selectedFeatureId: id }),
|
||||
|
||||
resetTool: () => set({
|
||||
activeTool: 'select',
|
||||
selectedFeatureId: null
|
||||
}),
|
||||
}))
|
||||
39
src/stores/ui-store.ts
Normal file
39
src/stores/ui-store.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type SidebarTab = 'map' | 'journal'
|
||||
export type ConnectionStatus = 'connected' | 'reconnecting' | 'offline'
|
||||
|
||||
interface UIStore {
|
||||
sidebarOpen: boolean
|
||||
sidebarTab: SidebarTab
|
||||
activeModal: string | null
|
||||
isEditing: boolean
|
||||
connectionStatus: ConnectionStatus
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
setSidebarTab: (tab: SidebarTab) => void
|
||||
openModal: (modal: string) => void
|
||||
closeModal: () => void
|
||||
setIsEditing: (editing: boolean) => void
|
||||
setConnectionStatus: (status: ConnectionStatus) => void
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIStore>((set) => ({
|
||||
sidebarOpen: true,
|
||||
sidebarTab: 'map',
|
||||
activeModal: null,
|
||||
isEditing: false,
|
||||
connectionStatus: 'offline',
|
||||
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setSidebarTab: (tab) => set({ sidebarTab: tab }),
|
||||
|
||||
openModal: (modal) => set({ activeModal: modal }),
|
||||
closeModal: () => set({ activeModal: null }),
|
||||
|
||||
setIsEditing: (editing) => set({ isEditing: editing }),
|
||||
setConnectionStatus: (status) => set({ connectionStatus: status }),
|
||||
}))
|
||||
70
src/types/index.ts
Normal file
70
src/types/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export interface Project {
|
||||
id: string
|
||||
title: string
|
||||
location?: string
|
||||
description?: string
|
||||
einsatzleiter?: string
|
||||
journalfuehrer?: string
|
||||
mapCenter: { lng: number; lat: number }
|
||||
mapZoom: number
|
||||
isLocked: boolean
|
||||
editingById?: string | null
|
||||
editingUserName?: string | null
|
||||
editingStartedAt?: string | null
|
||||
planImageKey?: string | null
|
||||
planBounds?: { north: number; south: number; east: number; west: number } | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DrawFeature {
|
||||
id: string
|
||||
type: string
|
||||
geometry: {
|
||||
type: string
|
||||
coordinates: number[] | number[][] | number[][][]
|
||||
}
|
||||
properties: Record<string, any>
|
||||
}
|
||||
|
||||
// Mapbox Feature Types
|
||||
export type Feature = {
|
||||
id?: string | number
|
||||
type: 'Feature'
|
||||
geometry: {
|
||||
type: string
|
||||
coordinates: any
|
||||
}
|
||||
properties: Record<string, any>
|
||||
}
|
||||
|
||||
export type DrawMode =
|
||||
| 'select'
|
||||
| 'point'
|
||||
| 'linestring'
|
||||
| 'polygon'
|
||||
| 'rectangle'
|
||||
| 'circle'
|
||||
| 'freehand'
|
||||
| 'text'
|
||||
| 'arrow'
|
||||
| 'measure'
|
||||
| 'dangerzone'
|
||||
| 'eraser'
|
||||
|
||||
export interface JournalEntry {
|
||||
id: string
|
||||
type: 'TEXT' | 'IMAGE' | 'AUDIO' | 'SOMA' | 'DANGER'
|
||||
content?: string
|
||||
timestamp: string
|
||||
userId?: string
|
||||
userName?: string
|
||||
userRole?: string
|
||||
isDone?: boolean
|
||||
fileUrl?: string
|
||||
fileKey?: string
|
||||
somaTemplateId?: string
|
||||
somaChecked?: boolean
|
||||
isCorrected?: boolean
|
||||
correctionOfId?: string
|
||||
}
|
||||
Reference in New Issue
Block a user