From ad021986716f51bd6bdad1c32aa9fbcd86dc4780 Mon Sep 17 00:00:00 2001 From: KNSONWS Date: Thu, 19 Mar 2026 21:13:55 +0100 Subject: [PATCH] fieles neues --- package-lock.json | 313 ++++++++++++- package.json | 6 +- scripts/seed-dummy-data.js | 271 +++++++++++ scripts/setup-appwrite.js | 8 + server/index.js | 92 ++++ src/App.jsx | 17 + src/components/AdminPanel.jsx | 95 +++- src/components/Dashboard.jsx | 59 +-- src/components/DefektForm.jsx | 4 +- src/components/DefektTable.jsx | 53 ++- src/components/DefektTrackApp.jsx | 12 +- src/components/FilialDetail.jsx | 26 +- src/components/FilialleiterDashboard.jsx | 178 ++++++- src/components/UserAssignDialog.jsx | 256 ++++++++++ src/components/UserCreateForm.jsx | 28 +- src/components/UserDetail.jsx | 572 +++++++++++++++++++++++ src/components/ui/chart.jsx | 312 +++++++++++++ src/hooks/useAssets.js | 31 +- src/hooks/useAuditLog.js | 26 +- 19 files changed, 2234 insertions(+), 125 deletions(-) create mode 100644 scripts/seed-dummy-data.js create mode 100644 src/components/UserAssignDialog.jsx create mode 100644 src/components/UserDetail.jsx create mode 100644 src/components/ui/chart.jsx diff --git a/package-lock.json b/package-lock.json index 293685a..a1523e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,9 +20,11 @@ "express": "^5.2.1", "lucide-react": "^0.577.0", "next-themes": "^0.4.6", + "node-appwrite": "^22.1.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", + "recharts": "^2.15.4", "shadcn": "^4.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -39,7 +41,6 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.14.0", - "node-appwrite": "^22.1.3", "prettier": "3.5.3", "vite": "^6.1.0" } @@ -99,7 +100,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1617,7 +1617,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2290,6 +2289,60 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2309,7 +2362,6 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2375,7 +2427,6 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2703,7 +2754,6 @@ "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -2775,7 +2825,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3196,9 +3245,118 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3279,6 +3437,11 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -3425,6 +3588,15 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -3771,7 +3943,6 @@ "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3998,6 +4169,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4050,7 +4226,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4113,6 +4288,14 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4731,7 +4914,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4850,6 +5032,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -5485,7 +5675,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dev": true, "license": "MIT", "dependencies": { "bignumber.js": "^9.0.0" @@ -5847,6 +6036,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5898,7 +6092,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -6183,7 +6376,6 @@ "version": "22.1.3", "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-22.1.3.tgz", "integrity": "sha512-pXxvojqgYY3HAiJAj+P6xb/CXVNsMUd+JXVOLrCCDzqZmpKeMF6OEt4mAoDEyKTnRaixUDHlPrdh7y1G/w3sbw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "json-bigint": "1.0.0", @@ -6232,7 +6424,6 @@ "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz", "integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==", - "dev": true, "license": "MIT" }, "node_modules/node-releases": { @@ -6654,7 +6845,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6801,7 +6991,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -6896,7 +7085,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6906,7 +7094,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -6918,7 +7105,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-refresh": { @@ -6982,6 +7168,35 @@ "url": "https://opencollective.com/express" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -6998,6 +7213,41 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8224,12 +8474,32 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz", "integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.2", @@ -8620,7 +8890,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9e3f497..2020e5e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "setup": "node scripts/setup-appwrite.js" + "setup": "node scripts/setup-appwrite.js", + "seed:dummy": "node scripts/seed-dummy-data.js" }, "dependencies": { "@appwrite.io/pink-icons": "^1.0.0", @@ -24,14 +25,15 @@ "express": "^5.2.1", "lucide-react": "^0.577.0", "next-themes": "^0.4.6", + "node-appwrite": "^22.1.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", + "recharts": "^2.15.4", "shadcn": "^4.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.0.14", - "node-appwrite": "^22.1.3", "tw-animate-css": "^1.4.0" }, "devDependencies": { diff --git a/scripts/seed-dummy-data.js b/scripts/seed-dummy-data.js new file mode 100644 index 0000000..ec569a8 --- /dev/null +++ b/scripts/seed-dummy-data.js @@ -0,0 +1,271 @@ +/** + * Erstellt Dummy-Daten: eine Dummy-Filiale mit Mitarbeitern, Lagerstandorten und Geräten. + * Voraussetzung: npm run setup bereits ausgeführt (Collections existieren). + * + * Ausführung: node scripts/seed-dummy-data.js + */ + +import { Client, Databases, Users, Teams, ID, Query } from 'node-appwrite'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadEnv() { + const envPath = resolve(__dirname, '..', '.env'); + try { + const lines = readFileSync(envPath, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + process.env[key] = value; + } + } catch { + console.error('Fehler: .env nicht gefunden. Bitte .env aus .env.example anlegen.'); + process.exit(1); + } +} + +loadEnv(); + +const ENDPOINT = process.env.APPWRITE_ENDPOINT; +const PROJECT_ID = process.env.VITE_APPWRITE_PROJECT_ID; +const API_KEY = process.env.APPWRITE_API_KEY; +const DATABASE_ID = process.env.VITE_APPWRITE_DATABASE_ID || 'defekttrack_db'; + +const DUMMY_PASSWORD = 'Dummy1234!'; + +if (!ENDPOINT || !PROJECT_ID || !API_KEY || API_KEY === 'YOUR_API_KEY_HERE') { + console.error('Bitte APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID und APPWRITE_API_KEY in .env setzen.'); + process.exit(1); +} + +const client = new Client() + .setEndpoint(ENDPOINT) + .setProject(PROJECT_ID) + .setKey(API_KEY); + +const databases = new Databases(client); +const users = new Users(client); +const teams = new Teams(client); + +/** Prüft ob die Dummy-Filiale bereits existiert */ +async function getOrCreateDummyLocation() { + const res = await databases.listDocuments(DATABASE_ID, 'locations', [ + Query.equal('name', ['Dummy-Filiale München']), + Query.limit(1), + ]); + if (res.documents.length > 0) { + console.log(`Dummy-Filiale existiert bereits: "${res.documents[0].name}" (${res.documents[0].$id})`); + return res.documents[0]; + } + const loc = await databases.createDocument(DATABASE_ID, 'locations', ID.unique(), { + name: 'Dummy-Filiale München', + address: 'Musterstraße 42, 80331 München', + isActive: true, + }); + console.log(`Filiale erstellt: "${loc.name}" (${loc.$id})`); + return loc; +} + +/** Erstellt oder findet einen Appwrite-User und users_meta */ +async function ensureUser(email, name, role, locationId) { + let userId; + const metaCheck = await databases.listDocuments(DATABASE_ID, 'users_meta', [ + Query.equal('userName', [name]), + Query.equal('locationId', [locationId]), + Query.limit(1), + ]); + if (metaCheck.documents.length > 0) { + userId = metaCheck.documents[0].userId; + const userCheck = await users.get(userId).catch(() => null); + if (userCheck) { + console.log(` User existiert: ${name} (${email})`); + return userId; + } + } + + try { + const user = await users.create(ID.unique(), email, undefined, DUMMY_PASSWORD, name); + userId = user.$id; + console.log(` User erstellt: ${name} (${email})`); + } catch (err) { + if (err.code === 409) { + const list = await users.list([Query.equal('email', [email])]); + if (list.users.length > 0) { + userId = list.users[0].$id; + console.log(` User existiert: ${name}`); + } else throw err; + } else throw err; + } + + try { + await teams.createMembership(role, [], email, userId, undefined, `${ENDPOINT}/auth/confirm`); + } catch (err) { + if (err.code !== 409) console.warn(` Team ${role}:`, err.message); + } + + const existingMeta = await databases.listDocuments(DATABASE_ID, 'users_meta', [ + Query.equal('userId', [userId]), + Query.limit(1), + ]); + if (existingMeta.documents.length === 0) { + await databases.createDocument(DATABASE_ID, 'users_meta', ID.unique(), { + userId, + locationId, + userName: name, + role, + mustChangePassword: false, + }); + console.log(` users_meta erstellt für ${name}`); + } + return userId; +} + +/** Erstellt Lagerstandorte für die Filiale, gibt alle zurück */ +async function createLagerstandorte(locationId) { + const names = ['Lager A - EG', 'Lager B - Keller', 'Reparaturwerkstatt']; + const existing = await databases.listDocuments(DATABASE_ID, 'lagerstandorte', [ + Query.equal('locationId', [locationId]), + Query.limit(20), + ]); + const existingNames = new Set(existing.documents.map((d) => d.name)); + + for (const name of names) { + if (existingNames.has(name)) { + console.log(` Lagerstandort existiert: ${name}`); + continue; + } + await databases.createDocument(DATABASE_ID, 'lagerstandorte', ID.unique(), { + name, + locationId, + isActive: true, + }); + existingNames.add(name); + console.log(` Lagerstandort erstellt: ${name}`); + } + const res = await databases.listDocuments(DATABASE_ID, 'lagerstandorte', [ + Query.equal('locationId', [locationId]), + Query.limit(20), + ]); + return res.documents; +} + +/** Erstellt Dummy-Mitarbeiter */ +async function createDummyMitarbeiter(locationId) { + const mitarbeiter = [ + { email: 'dummy-filialleiter@defekttrack.local', name: 'Max Mustermann', role: 'filialleiter' }, + { email: 'dummy-service1@defekttrack.local', name: 'Lisa Schmidt', role: 'service' }, + { email: 'dummy-service2@defekttrack.local', name: 'Thomas Weber', role: 'service' }, + { email: 'dummy-service3@defekttrack.local', name: 'Anna Becker', role: 'service' }, + { email: 'dummy-lager@defekttrack.local', name: 'Peter Krause', role: 'lager' }, + ]; + const userNames = []; + for (const m of mitarbeiter) { + await ensureUser(m.email, m.name, m.role, locationId); + userNames.push(m.name); + await new Promise((r) => setTimeout(r, 300)); + } + return userNames; +} + +/** Erstellt Dummy-Geräte (Assets) */ +async function createDummyAssets(locationId, lagerstandorte, userNames) { + const statuses = ['offen', 'in_bearbeitung', 'entsorgt']; + const prioritaeten = ['hoch', 'mittel', 'niedrig']; + const geraete = [ + { erl: 'ERL-001', serien: 'SN-100001', artikel: 'ART-101', bezeichnung: 'Laptop Dell Latitude 5520', defekt: 'Display defekt' }, + { erl: 'ERL-002', serien: 'SN-100002', artikel: 'ART-102', bezeichnung: 'Monitor LG 27"', defekt: 'Kein Bild' }, + { erl: 'ERL-003', serien: 'SN-100003', artikel: 'ART-103', bezeichnung: 'Tastatur Logitech MX', defekt: 'Tasten kleben' }, + { erl: 'ERL-004', serien: 'SN-100004', artikel: 'ART-104', bezeichnung: 'Maus Microsoft', defekt: 'Scroller defekt' }, + { erl: 'ERL-005', serien: 'SN-100005', artikel: 'ART-105', bezeichnung: 'Drucker HP LaserJet', defekt: 'Papierstau' }, + { erl: 'ERL-006', serien: 'SN-100006', artikel: 'ART-106', bezeichnung: 'Router Cisco', defekt: 'Keine Verbindung' }, + { erl: 'ERL-007', serien: 'SN-100007', artikel: 'ART-107', bezeichnung: 'Smartphone Samsung', defekt: 'Akku schwach' }, + { erl: 'ERL-008', serien: 'SN-100008', artikel: 'ART-108', bezeichnung: 'Tablet iPad', defekt: 'Display Riss' }, + { erl: 'ERL-009', serien: 'SN-100009', artikel: 'ART-109', bezeichnung: 'Switch Netgear', defekt: 'LED defekt' }, + { erl: 'ERL-010', serien: 'SN-100010', artikel: 'ART-110', bezeichnung: 'Festplatte SSD 1TB', defekt: 'Lesefehler' }, + { erl: 'ERL-011', serien: 'SN-100011', artikel: 'ART-111', bezeichnung: 'USB-Hub 4-Port', defekt: 'Port 3 defekt' }, + { erl: 'ERL-012', serien: 'SN-100012', artikel: 'ART-112', bezeichnung: 'Webcam Logitech', defekt: 'Mikrofon rauscht' }, + { erl: 'ERL-013', serien: 'SN-100013', artikel: 'ART-113', bezeichnung: 'Headset Jabra', defekt: 'Kabelbruch' }, + { erl: 'ERL-014', serien: 'SN-100014', artikel: 'ART-114', bezeichnung: 'Kabel HDMI 2m', defekt: 'Kontakt defekt' }, + { erl: 'ERL-015', serien: 'SN-100015', artikel: 'ART-115', bezeichnung: 'Laptop Lenovo ThinkPad', defekt: 'Lüfter laut' }, + ]; + + const existing = await databases.listDocuments(DATABASE_ID, 'assets', [ + Query.startsWith('erlNummer', ['ERL-00']), + Query.limit(50), + ]); + const existingErl = new Set(existing.documents.map((d) => d.erlNummer)); + + let created = 0; + for (let i = 0; i < geraete.length; i++) { + const g = geraete[i]; + if (existingErl.has(g.erl)) continue; + const ls = lagerstandorte[i % lagerstandorte.length]; + const zustaendig = userNames[i % userNames.length]; + const status = statuses[i % statuses.length]; + const prio = prioritaeten[i % prioritaeten.length]; + + await databases.createDocument(DATABASE_ID, 'assets', ID.unique(), { + erlNummer: g.erl, + seriennummer: g.serien, + artikelNr: g.artikel, + bezeichnung: g.bezeichnung, + defekt: g.defekt, + lagerstandortId: ls?.$id || '', + zustaendig, + status, + prio, + kommentar: i % 3 === 0 ? 'Dummy-Eintrag für Testzwecke' : '', + createdBy: 'Seed-Script', + lastEditedBy: 'Seed-Script', + }); + created++; + console.log(` Gerät erstellt: ${g.erl} (${g.bezeichnung}) - ${status}`); + if (created % 3 === 0) await new Promise((r) => setTimeout(r, 200)); + } + if (created === 0) console.log(' Alle Dummy-Geräte existieren bereits.'); + return created; +} + +async function main() { + console.log('=== DefektTrack Dummy-Daten Seed ==='); + console.log(''); + + const loc = await getOrCreateDummyLocation(); + const locationId = loc.$id; + + console.log(''); + console.log('--- Dummy-Mitarbeiter ---'); + const userNames = await createDummyMitarbeiter(locationId); + + console.log(''); + console.log('--- Lagerstandorte ---'); + const lsList = await createLagerstandorte(locationId); + + console.log(''); + console.log('--- Dummy-Geräte ---'); + await createDummyAssets(locationId, lsList, userNames); + + console.log(''); + console.log('=== Dummy-Daten Seed abgeschlossen ==='); + console.log(''); + console.log('Filiale: Dummy-Filiale München'); + console.log('Mitarbeiter-Logins (Passwort: Dummy1234!):'); + console.log(' - dummy-filialleiter@defekttrack.local (Max Mustermann, Filialleiter)'); + console.log(' - dummy-service1@defekttrack.local (Lisa Schmidt)'); + console.log(' - dummy-service2@defekttrack.local (Thomas Weber)'); + console.log(' - dummy-service3@defekttrack.local (Anna Becker)'); + console.log(' - dummy-lager@defekttrack.local (Peter Krause)'); + console.log(''); +} + +main().catch((err) => { + console.error('Seed fehlgeschlagen:', err); + process.exit(1); +}); diff --git a/scripts/setup-appwrite.js b/scripts/setup-appwrite.js index 89d621e..4267c49 100644 --- a/scripts/setup-appwrite.js +++ b/scripts/setup-appwrite.js @@ -223,6 +223,14 @@ async function createAuditLogsCollection() { else throw err; } + try { + await databases.createIndex(DATABASE_ID, COLLECTION_ID, 'idx_userId', 'key', ['userId'], ['ASC']); + console.log(' Index erstellt: idx_userId'); + } catch (err) { + if (err.code === 409) console.log(' Index existiert bereits: idx_userId'); + else throw err; + } + console.log(' Attribute fuer audit_logs erstellt (assetId, action, details, userId, userName)'); } diff --git a/server/index.js b/server/index.js index 994f0cd..dc8c5f3 100644 --- a/server/index.js +++ b/server/index.js @@ -125,6 +125,98 @@ app.post('/api/admin/create-user', requireAdminSecret, async (req, res) => { } }); +app.patch('/api/admin/update-user', requireAdminSecret, async (req, res) => { + try { + const { userId: targetUserId, userName, locationId, role: newRole } = req.body || {}; + if (!targetUserId) { + return res.status(400).json({ error: 'userId erforderlich' }); + } + if (!ENDPOINT || !PROJECT_ID || !API_KEY) { + return res.status(500).json({ error: 'Server-Konfiguration unvollständig' }); + } + + const client = new Client().setEndpoint(ENDPOINT).setProject(PROJECT_ID).setKey(API_KEY); + const users = new Users(client); + const teams = new Teams(client); + const db = new Databases(client); + + const metaRes = await db.listDocuments(DATABASE_ID, 'users_meta', [ + Query.equal('userId', [targetUserId]), + Query.limit(1), + ]); + const metaDoc = metaRes.documents[0]; + if (!metaDoc) { + return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + } + + const updates = {}; + const newUserName = userName !== undefined ? String(userName).trim() : null; + if (newUserName !== null) updates.userName = newUserName; + if (locationId !== undefined) updates.locationId = locationId || ''; + + if (newUserName && newUserName !== metaDoc.userName) { + const assetsRes = await db.listDocuments(DATABASE_ID, 'assets', [ + Query.equal('zustaendig', [metaDoc.userName]), + Query.limit(500), + ]); + for (const a of assetsRes.documents) { + await db.updateDocument(DATABASE_ID, 'assets', a.$id, { zustaendig: newUserName }); + } + } + + if (newRole !== undefined) { + if (!TEAM_ROLES.includes(newRole)) { + return res.status(400).json({ error: 'Ungültige Rolle', allowed: TEAM_ROLES }); + } + updates.role = newRole; + + const appUser = await users.get(targetUserId); + const email = appUser.email; + + for (const teamId of TEAM_ROLES) { + try { + const list = await teams.listMemberships(teamId, [Query.limit(100)]); + const membership = list.memberships.find((m) => m.userId === targetUserId); + if (membership) { + await teams.deleteMembership(teamId, membership.$id); + } + } catch (e) { + if (e.code !== 404) console.warn('deleteMembership:', e.message); + } + } + try { + await teams.createMembership(newRole, [], email, targetUserId, undefined, `${ENDPOINT}/auth/confirm`); + } catch (err) { + if (err.code !== 409) console.warn('createMembership:', err.message); + } + } + + if (Object.keys(updates).length > 0) { + if (updates.userName && metaDoc.userName) { + try { + const assetsRes = await db.listDocuments(DATABASE_ID, 'assets', [ + Query.equal('zustaendig', [metaDoc.userName]), + Query.limit(500), + ]); + for (const a of assetsRes.documents) { + await db.updateDocument(DATABASE_ID, 'assets', a.$id, { + zustaendig: updates.userName, + }); + } + } catch (e) { + console.warn('Asset zustaendig-Update:', e.message); + } + } + await db.updateDocument(DATABASE_ID, 'users_meta', metaDoc.$id, updates); + } + + return res.status(200).json({ userId: targetUserId, ...updates }); + } catch (err) { + console.error('update-user error:', err); + return res.status(500).json({ error: err.message || 'Interner Serverfehler' }); + } +}); + const PORT = process.env.API_PORT || 3001; app.listen(PORT, () => { console.log(`API server http://localhost:${PORT}`); diff --git a/src/App.jsx b/src/App.jsx index 87fefb7..789cfb1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,7 @@ import RoleRedirect from './components/RoleRedirect'; import DefektTrackApp from './components/DefektTrackApp'; import AssetDetail from './components/AssetDetail'; import AdminPanel from './components/AdminPanel'; +import UserDetail from './components/UserDetail'; import FilialleiterDashboard from './components/FilialleiterDashboard'; import FirmenleiterDashboard from './components/FirmenleiterDashboard'; @@ -52,6 +53,14 @@ function App() { } /> + + + + } + /> } /> + + + + } + /> !usersRes.documents.some((u) => u.locationId === loc.$id && u.role === 'filialleiter') ).length; @@ -115,6 +131,19 @@ export default function AdminPanel() { } } + const filteredUsers = users.filter( + (u) => + !userSearchQuery.trim() || + (u.userName || '').toLowerCase().includes(userSearchQuery.toLowerCase()) || + (u.userId || '').toLowerCase().includes(userSearchQuery.toLowerCase()) + ); + + const getLocationName = (locationId) => { + if (!locationId) return 'Nicht zugeordnet'; + const loc = locations.find((l) => l.$id === locationId); + return loc?.name || 'Unbekannte Filiale'; + }; + const statItems = [ { label: 'Benutzer', value: stats.users }, { label: 'Filialen', value: stats.locations }, @@ -226,6 +255,70 @@ export default function AdminPanel() { + + + + Benutzer verwaltung + + +
+ setUserSearchQuery(e.target.value)} + className="max-w-xs" + /> + +
+ +
+ {users + .filter((u) => + (u.userName || u.userId || '') + .toLowerCase() + .includes(userSearchQuery.toLowerCase()) + ) + .map((u) => ( + + ))} + {users.filter((u) => + (u.userName || u.userId || '').toLowerCase().includes(userSearchQuery.toLowerCase()) + ).length === 0 && ( +

+ {userSearchQuery ? 'Keine Benutzer gefunden' : 'Keine Benutzer vorhanden'} +

+ )} +
+
+
+
+ + {showUserForm && ( + { + setShowUserForm(false); + loadData(); + }} + onCancel={() => setShowUserForm(false)} + showToast={showToast} + /> + )} ); diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 37d48de..ffecc0b 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -1,7 +1,4 @@ -import { useState } from 'react'; import { isOverdue } from '../hooks/useAssets'; -import { useAuth } from '../context/AuthContext'; -import LagerstandortManager from './LagerstandortManager'; import { Card, CardContent } from '@/components/ui/card'; const STAT_CARDS = [ @@ -11,9 +8,7 @@ const STAT_CARDS = [ { key: 'overdue', color: '#2563EB', label: 'Überfällig (>7 Tage)' }, ]; -export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort, onToggleLagerstandort, onDeleteLagerstandort }) { - const { isAdmin, isFilialleiter } = useAuth(); - const [showManager, setShowManager] = useState(false); +export default function Dashboard({ assets, statusFilter, onStatusFilterChange }) { const counts = { offen: assets.filter((a) => a.status === 'offen').length, @@ -22,41 +17,33 @@ export default function Dashboard({ assets, lagerstandorte, onAddLagerstandort, overdue: assets.filter(isOverdue).length, }; - return ( - <> -
- {STAT_CARDS.map(({ key, color, label }) => ( - - -
{counts[key]}
-

{label}

-
-
- ))} + const handleCardClick = (key) => { + onStatusFilterChange?.(statusFilter === key ? null : key); + }; - {(isAdmin || isFilialleiter) && ( + return ( +
+ {STAT_CARDS.map(({ key, color, label }) => { + const isSelected = statusFilter === key; + return ( setShowManager(true)} + key={key} + className="py-0 cursor-pointer transition-all duration-200 hover:opacity-90" + style={{ + borderTop: `3px solid ${color}`, + ...(isSelected && { + backgroundColor: `${color}30`, + }), + }} + onClick={() => handleCardClick(key)} > -
{lagerstandorte.length}
-

Lagerstandorte verwalten

+
{counts[key]}
+

{label}

- )} -
- - {showManager && ( - setShowManager(false)} - /> - )} - + ); + })} +
); } diff --git a/src/components/DefektForm.jsx b/src/components/DefektForm.jsx index 93eaae6..e7bf013 100644 --- a/src/components/DefektForm.jsx +++ b/src/components/DefektForm.jsx @@ -74,10 +74,10 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague return ( - + Defekte Ware erfassen - +
diff --git a/src/components/DefektTable.jsx b/src/components/DefektTable.jsx index fa781c0..4a0bd34 100644 --- a/src/components/DefektTable.jsx +++ b/src/components/DefektTable.jsx @@ -4,7 +4,6 @@ import { getDaysOld, isOverdue } from '../hooks/useAssets'; import { useAuth } from '../context/AuthContext'; import CommentPopup from './CommentPopup'; import ColumnFilter, { TextFilter, SelectFilter } from './ColumnFilter'; -import { Card } from '@/components/ui/card'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -20,6 +19,14 @@ const PRIO_COLORS = { niedrig: 'bg-green-500', }; +/** Prioritätsfarbe füllt die komplette ERL-Nr.-Zelle als Hintergrund */ +const PRIO_ERL_CELL = { + kritisch: '!bg-red-600 text-red-50', + hoch: '!bg-orange-500 text-orange-50', + mittel: '!bg-yellow-400 text-yellow-950', + niedrig: '!bg-green-500 text-green-50', +}; + const SORT_OPTIONS = [ { value: 'prio', label: 'Priorität' }, { value: 'newest', label: 'Neueste zuerst' }, @@ -27,12 +34,6 @@ const SORT_OPTIONS = [ { value: 'mine', label: 'Mir zugewiesen' }, ]; -const STATUS_OPTIONS = [ - { value: 'offen', label: 'Offen' }, - { value: 'in_bearbeitung', label: 'In Bearbeitung' }, - { value: 'entsorgt', label: 'Entsorgt' }, -]; - const STATUS_BADGE_CONFIG = { offen: { variant: 'destructive' }, in_bearbeitung: { variant: 'default', className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' }, @@ -51,7 +52,9 @@ function resolveStandortName(asset, lagerstandorte) { return ls ? ls.name : '–'; } -export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte }) { +const STATUS_FILTER_MAP = { offen: 'offen', bearbeitung: 'in_bearbeitung', entsorgt: 'entsorgt' }; + +export default function DefektTable({ assets, onChangeStatus, showToast, lagerstandorte, statusFilter }) { const { user } = useAuth(); const navigate = useNavigate(); const [activeFilter, setActiveFilter] = useState(null); @@ -63,7 +66,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst seriennummer: '', defekt: '', standort: '', - status: '', sortBy: 'prio', }); @@ -90,7 +92,11 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst if (filters.seriennummer && !(a.seriennummer || '').toLowerCase().includes(filters.seriennummer.toLowerCase())) return false; if (filters.defekt && !(a.defekt || '').toLowerCase().includes(filters.defekt.toLowerCase())) return false; if (filters.standort && a.lagerstandortId !== filters.standort) return false; - if (filters.status && a.status !== filters.status) return false; + if (statusFilter === 'overdue') { + if (!isOverdue(a)) return false; + } else if (statusFilter && STATUS_FILTER_MAP[statusFilter]) { + if (a.status !== STATUS_FILTER_MAP[statusFilter]) return false; + } return true; }); @@ -113,7 +119,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst } return result; - }, [assets, filters, user]); + }, [assets, filters, user, statusFilter]); function handlePrint() { const printable = filtered.filter((a) => a.status === 'offen' || a.status === 'in_bearbeitung'); @@ -185,7 +191,7 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst const standortOptions = (lagerstandorte || []).map((l) => ({ value: l.$id, label: l.name })); return ( - +
{filtered.length} Assets @@ -219,10 +225,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst setFilter('standort', v)} options={standortOptions} /> - openFilter('status')} onClose={closeFilter}> - setFilter('status', v)} options={STATUS_OPTIONS} /> - - openFilter('sort')} onClose={closeFilter}> { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} /> @@ -232,23 +234,26 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst - {filtered.map((a) => { + {filtered.map((a, index) => { const days = getDaysOld(a.$createdAt); const overdue = isOverdue(a); const ageText = days === 0 ? 'Heute' : days === 1 ? '1 Tag' : `${days} Tage`; const badgeCfg = STATUS_BADGE_CONFIG[a.status] || STATUS_BADGE_CONFIG.offen; const statusBtnCfg = STATUS_BUTTON_CONFIG[a.status] || STATUS_BUTTON_CONFIG.offen; + const rowClassName = overdue + ? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20' + : index % 2 === 0 + ? 'bg-muted/50' + : ''; + return ( - -
- - {a.erlNummer || '–'} -
+ + {a.erlNummer || '–'} @@ -324,6 +329,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst {commentAsset && ( setCommentAsset(null)} /> )} - +
); } diff --git a/src/components/DefektTrackApp.jsx b/src/components/DefektTrackApp.jsx index d70440d..6d57d20 100644 --- a/src/components/DefektTrackApp.jsx +++ b/src/components/DefektTrackApp.jsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import Header from './Header'; import Dashboard from './Dashboard'; import DefektForm from './DefektForm'; @@ -13,13 +13,14 @@ import { useAuth } from '../context/AuthContext'; export default function DefektTrackApp() { const { user, userMeta } = useAuth(); const locationId = userMeta?.locationId || ''; - const { assets, addAsset, changeStatus } = useAssets(); + const { assets, addAsset, changeStatus } = useAssets(locationId); const { addLog } = useAuditLog(); const { lagerstandorte, activeLagerstandorte, addLagerstandort, toggleLagerstandort, deleteLagerstandort } = useLagerstandorte(locationId); const { colleagues } = useColleagues(locationId); const { showToast } = useToast(); const userName = user?.name || user?.email || 'Unbekannt'; + const [statusFilter, setStatusFilter] = useState(null); const handleAdd = useCallback(async (data) => { const doc = await addAsset({ ...data, createdBy: userName, lastEditedBy: userName }); @@ -74,10 +75,8 @@ export default function DefektTrackApp() {
@@ -87,6 +86,7 @@ export default function DefektTrackApp() { onChangeStatus={handleStatusChange} showToast={showToast} lagerstandorte={lagerstandorte} + statusFilter={statusFilter} />
diff --git a/src/components/FilialDetail.jsx b/src/components/FilialDetail.jsx index 4f70e72..6a77c49 100644 --- a/src/components/FilialDetail.jsx +++ b/src/components/FilialDetail.jsx @@ -3,6 +3,7 @@ import { databases, DATABASE_ID } from '@/lib/appwrite'; import { Query } from 'appwrite'; import { useLagerstandorte } from '@/hooks/useLagerstandorte'; import LagerstandortManager from './LagerstandortManager'; +import UserAssignDialog from './UserAssignDialog'; import UserCreateForm from './UserCreateForm'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -20,6 +21,7 @@ const ROLE_LABELS = { export default function FilialDetail({ location: loc, onClose, showToast, onUserAdded }) { const [users, setUsers] = useState([]); + const [showAssignDialog, setShowAssignDialog] = useState(false); const [showUserForm, setShowUserForm] = useState(false); const [showLsManager, setShowLsManager] = useState(false); @@ -91,9 +93,14 @@ export default function FilialDetail({ location: loc, onClose, showToast, onUser Benutzer dieser Filiale - +
+ + +
{users.length === 0 ? (

Keine Benutzer

@@ -120,6 +127,19 @@ export default function FilialDetail({ location: loc, onClose, showToast, onUser /> )} + {showAssignDialog && ( + setShowAssignDialog(false)} + onSuccess={() => { + loadUsers(); + setShowAssignDialog(false); + onUserAdded?.(); + }} + showToast={showToast} + /> + )} + {showUserForm && ( { + if (a.status !== 'entsorgt') return false; + const d = new Date(a.$updatedAt || a.$createdAt); + return d >= start && d <= end; + }).length; +} + +function countUeberfaelligAt(assets, endOfPeriod) { + const cutoff = new Date(endOfPeriod); + cutoff.setDate(cutoff.getDate() - 7); + return assets.filter((a) => { + const created = new Date(a.$createdAt); + if (created > cutoff) return false; + if (a.status === 'entsorgt') { + const disposed = new Date(a.$updatedAt || a.$createdAt); + return disposed > endOfPeriod; + } + return true; + }).length; +} + export default function FilialleiterDashboard() { + const navigate = useNavigate(); const { userMeta } = useAuth(); const { showToast } = useToast(); const locationId = userMeta?.locationId || ''; @@ -55,15 +87,21 @@ export default function FilialleiterDashboard() { const loadData = useCallback(async () => { if (!locationId) return; try { - const [assetsRes, metaRes, locsRes] = await Promise.all([ + const [assetsRes, lagerRes, metaRes, locsRes] = await Promise.all([ databases.listDocuments(DATABASE_ID, 'assets', [Query.limit(500)]), + databases.listDocuments(DATABASE_ID, 'lagerstandorte', [ + Query.equal('locationId', [locationId]), + Query.limit(200), + ]), databases.listDocuments(DATABASE_ID, 'users_meta', [ Query.equal('locationId', [locationId]), Query.limit(100), ]), databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]), ]); - setOwnAssets(assetsRes.documents); + const lagerIds = new Set(lagerRes.documents.map((l) => l.$id)); + const assetsForLocation = assetsRes.documents.filter((a) => a.lagerstandortId && lagerIds.has(a.lagerstandortId)); + setOwnAssets(assetsForLocation); setAllAssetsTotal(assetsRes.total); setAllLocationsCount(Math.max(locsRes.total, 1)); setColleagues(metaRes.documents.filter((d) => d.userName)); @@ -86,6 +124,57 @@ export default function FilialleiterDashboard() { const thisMonthCount = countInRange(ownAssets, monthStart, now); const lastMonthCount = countInRange(ownAssets, lastMonthStart, lastMonthEnd); + const chartData = useMemo(() => { + const days = []; + const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + for (let i = 6; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59); + days.push({ + day: `${dayNames[d.getDay()]} ${d.getDate()}.`, + erfasst: countInRange(ownAssets, dayStart, dayEnd), + erledigt: countErledigtInRange(ownAssets, dayStart, dayEnd), + ueberfaellig: countUeberfaelligAt(ownAssets, dayEnd), + }); + } + return days; + }, [ownAssets]); + + const monthChartData = useMemo(() => { + const months = []; + const monthNames = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']; + for (let i = 5; i >= 0; i--) { + const d = new Date(); + d.setMonth(d.getMonth() - i); + const monthStart = new Date(d.getFullYear(), d.getMonth(), 1); + const monthEnd = new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59); + months.push({ + month: `${monthNames[d.getMonth()]} ${d.getFullYear().toString().slice(2)}`, + erfasst: countInRange(ownAssets, monthStart, monthEnd), + erledigt: countErledigtInRange(ownAssets, monthStart, monthEnd), + ueberfaellig: countUeberfaelligAt(ownAssets, monthEnd), + }); + } + return months; + }, [ownAssets]); + + const chartConfig = { + erfasst: { + label: 'Erfasst', + color: '#60a5fa', + }, + erledigt: { + label: 'Erledigt', + color: '#22c55e', + }, + ueberfaellig: { + label: 'Überfällig', + color: '#ef4444', + }, + }; + const avgAllFilialen = allLocationsCount > 0 ? Math.round(allAssetsTotal / allLocationsCount) : 0; const ownTotal = ownAssets.length; @@ -96,6 +185,7 @@ export default function FilialleiterDashboard() { const open = assigned.filter((a) => a.status === 'offen').length; const inProgress = assigned.filter((a) => a.status === 'in_bearbeitung').length; return { + userId: c.userId, name: c.userName, total: assigned.length, resolved, @@ -127,22 +217,72 @@ export default function FilialleiterDashboard() {
- - -
{todayCount}
-

Heute erfasst

-

- {dayTrend.arrow} Gestern: {yesterdayCount} -

+ + +
+
+
{todayCount}
+

Heute erfasst

+

+ {dayTrend.arrow} Gestern: {yesterdayCount} +

+
+
+
+ + + + + Math.floor(v)} /> + } cursor={false} /> + } /> + + + + + +
- - -
{thisMonthCount}
-

Diesen Monat

-

- {monthTrend.arrow} Letzter Monat: {lastMonthCount} -

+ + +
+
{thisMonthCount}
+

Diesen Monat

+

+ {monthTrend.arrow} Letzter Monat: {lastMonthCount} +

+
+
+ + + + + Math.floor(v)} /> + } cursor={false} /> + } /> + + + + + +
@@ -200,7 +340,11 @@ export default function FilialleiterDashboard() { {employeeStats.map((e) => ( - + e.userId && navigate(`/filialleiter/mitarbeiter/${e.userId}`)} + > {e.name} {e.total} {e.open} diff --git a/src/components/UserAssignDialog.jsx b/src/components/UserAssignDialog.jsx new file mode 100644 index 0000000..244fb46 --- /dev/null +++ b/src/components/UserAssignDialog.jsx @@ -0,0 +1,256 @@ +import { useState, useEffect, useCallback } from 'react'; +import { databases, DATABASE_ID } from '@/lib/appwrite'; +import { Query } from 'appwrite'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import UserCreateForm from './UserCreateForm'; + +const ROLE_LABELS = { + admin: 'Admin', + firmenleiter: 'Firmenleiter', + filialleiter: 'Filialleiter', + service: 'Service', + lager: 'Lager', +}; + +function getLocationName(locationId, locations) { + if (!locationId) return 'Nicht zugeordnet'; + const loc = locations.find((l) => l.$id === locationId); + return loc?.name || 'Unbekannte Filiale'; +} + +export default function UserAssignDialog({ + location: loc, + onClose, + onSuccess, + showToast, +}) { + const [allUsers, setAllUsers] = useState([]); + const [locations, setLocations] = useState([]); + const [loading, setLoading] = useState(true); + const [updatingId, setUpdatingId] = useState(null); + const [showUserForm, setShowUserForm] = useState(false); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [usersRes, locsRes] = await Promise.all([ + databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(500)]), + databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]), + ]); + setAllUsers(usersRes.documents); + setLocations(locsRes.documents); + } catch (err) { + console.error('Daten laden fehlgeschlagen:', err); + showToast?.('Fehler beim Laden', '#C62828'); + } finally { + setLoading(false); + } + }, [showToast]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const unassigned = allUsers.filter((u) => !u.locationId || u.locationId === ''); + const assignedToThis = allUsers.filter((u) => u.locationId === loc?.$id); + + async function handleAssign(doc) { + if (!loc?.$id) return; + setUpdatingId(doc.$id); + try { + await databases.updateDocument(DATABASE_ID, 'users_meta', doc.$id, { + locationId: loc.$id, + }); + showToast?.(`${doc.userName || doc.userId} zugeordnet`); + loadData(); + } catch (err) { + showToast?.('Fehler: ' + (err.message || err), '#C62828'); + } finally { + setUpdatingId(null); + } + } + + async function handleUnassign(doc) { + setUpdatingId(doc.$id); + try { + await databases.updateDocument(DATABASE_ID, 'users_meta', doc.$id, { + locationId: '', + }); + showToast?.(`${doc.userName || doc.userId} entfernt`); + loadData(); + } catch (err) { + showToast?.('Fehler: ' + (err.message || err), '#C62828'); + } finally { + setUpdatingId(null); + } + } + + async function handleReassign(doc, newLocationId) { + setUpdatingId(doc.$id); + try { + await databases.updateDocument(DATABASE_ID, 'users_meta', doc.$id, { + locationId: newLocationId || '', + }); + showToast?.(`${doc.userName || doc.userId} neu zugeordnet`); + loadData(); + } catch (err) { + showToast?.('Fehler: ' + (err.message || err), '#C62828'); + } finally { + setUpdatingId(null); + } + } + + if (showUserForm) { + return ( + { + setShowUserForm(false); + loadData(); + onSuccess?.(); + }} + onCancel={() => setShowUserForm(false)} + showToast={showToast} + /> + ); + } + + return ( + !open && onClose()}> + + + Benutzer zuordnen + + Bestehende Benutzer der Filiale {loc?.name} zuordnen oder neu anlegen + + + + {loading ? ( +

Laden...

+ ) : ( +
+
+ +
+ + {/* Oben: Nicht zugeordnete Benutzer */} + {unassigned.length > 0 && ( +
+

+ Noch nicht zugeordnet +

+ +
+ {unassigned.map((u) => ( +
+
+ {u.userName || u.userId} + + {getLocationName(u.locationId, locations)} + + + {ROLE_LABELS[u.role] || u.role} + +
+ +
+ ))} +
+
+
+ )} + + {/* Unten: Bereits zugeordnete Benutzer */} + {assignedToThis.length > 0 && ( +
+

+ Bereits dieser Filiale zugeordnet +

+ +
+ {assignedToThis.map((u) => ( +
+
+ {u.userName || u.userId} + + {ROLE_LABELS[u.role] || u.role} + +
+
+ + +
+
+ ))} +
+
+
+ )} + + {unassigned.length === 0 && assignedToThis.length === 0 && ( +

+ Keine Benutzer gefunden. Klicken Sie auf "Neuer Benutzer" um einen anzulegen. +

+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/UserCreateForm.jsx b/src/components/UserCreateForm.jsx index e567c7d..7d59c44 100644 --- a/src/components/UserCreateForm.jsx +++ b/src/components/UserCreateForm.jsx @@ -26,16 +26,19 @@ const ROLE_OPTIONS = [ const API_BASE = import.meta.env.VITE_API_URL || ''; -export default function UserCreateForm({ locationId, locationName, onSuccess, onCancel, showToast }) { +export default function UserCreateForm({ locationId: initialLocationId, locationName, locations = [], onSuccess, onCancel, showToast }) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); const [role, setRole] = useState('service'); + const [locationId, setLocationId] = useState(initialLocationId || ''); const [submitting, setSubmitting] = useState(false); + const effectiveLocationId = initialLocationId || locationId; + async function handleSubmit(e) { e.preventDefault(); - if (!email.trim() || !password || !name.trim() || !locationId) return; + if (!email.trim() || !password || !name.trim() || !effectiveLocationId) return; setSubmitting(true); try { const res = await fetch(`${API_BASE}/api/admin/create-user`, { @@ -45,7 +48,7 @@ export default function UserCreateForm({ locationId, locationName, onSuccess, on email: email.trim(), password, name: name.trim(), - locationId, + locationId: effectiveLocationId, role, mustChangePassword: false, }), @@ -71,7 +74,7 @@ export default function UserCreateForm({ locationId, locationName, onSuccess, on Benutzer hinzufügen - Neuer Benutzer für Filiale {locationName || locationId} + {initialLocationId ? `Neuer Benutzer für Filiale ${locationName || initialLocationId}` : 'Neuen Benutzer anlegen und Standort zuweisen'} @@ -115,6 +118,23 @@ export default function UserCreateForm({ locationId, locationName, onSuccess, on className="mt-1" />
+ {!initialLocationId && locations.length > 0 && ( +
+ + +
+ )}
setEditNameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleUpdateName(editNameValue); + if (e.key === 'Escape') { setEditingName(false); setEditNameValue(''); } + }} + onBlur={() => handleUpdateName(editNameValue)} + disabled={saving} + className="h-9 max-w-[300px] text-lg font-bold" + autoFocus + /> + + + ) : ( + <> +

+ {userMeta?.userName || userId} +

+ {!isReadOnly && ( + + )} + + )} +
+ {!isReadOnly && ( +
+ + +
+ )} +
+ +
+ {/* Audit-Logs */} + + + Benutzer-Logs + + + + {loadingLogs ? ( +

Laden...

+ ) : logs.length === 0 ? ( +

+ Keine Logs im gewählten Zeitraum +

+ ) : ( + +
+ {logs.map((log) => ( +
+
+ {log.action} + + {formatDate(log.$createdAt)} + +
+ {log.details && ( +

{log.details}

+ )} + {log.assetId && log.assetId !== 'profile' && ( + + )} +
+ ))} +
+
+ )} +
+
+ + {/* Aufgaben */} + + + Aufgaben (zugeordnete Assets) + + +
+ {['offen', 'in_bearbeitung', 'entsorgt', 'erledigt'].map((status) => ( +
+

+ {STATUS_LABELS[status]} ({byStatus[status]?.length || 0}) +

+ +
+ {(byStatus[status] || []).map((a) => ( +
+ {a.erlNummer || a.bezeichnung || a.$id} + +
+ ))} +
+
+
+ ))} +
+
+
+
+ + {/* Stats / Monatsvergleich */} + + + Monats-Statistik + + + {monthlyStats.length === 0 ? ( +

+ Noch keine auswertbaren Daten +

+ ) : ( +
+ {monthlyStats.map(({ month, actions = 0, completed = 0 }) => ( +
+ {month} +
{actions} Aktionen
+
+ {completed} abgeschlossen +
+
+
+
+
+ ))} +
+ )} + + +
+ + ); +} diff --git a/src/components/ui/chart.jsx b/src/components/ui/chart.jsx new file mode 100644 index 0000000..69e7b9c --- /dev/null +++ b/src/components/ui/chart.jsx @@ -0,0 +1,312 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { + light: "", + dark: ".dark" +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ + id, + config +}) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color) + + if (!colorConfig.length) { + return null + } + + return ( +