fieles neues

This commit is contained in:
KNSONWS
2026-03-19 21:13:55 +01:00
parent 9a39120919
commit ad02198671
19 changed files with 2234 additions and 125 deletions

313
package-lock.json generated
View File

@@ -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"
}

View File

@@ -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": {

271
scripts/seed-dummy-data.js Normal file
View File

@@ -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);
});

View File

@@ -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)');
}

View File

@@ -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}`);

View File

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/admin/user/:userId"
element={
<ProtectedRoute>
<UserDetail />
</ProtectedRoute>
}
/>
<Route
path="/filialleiter"
@@ -61,6 +70,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/filialleiter/mitarbeiter/:userId"
element={
<ProtectedRoute>
<UserDetail />
</ProtectedRoute>
}
/>
<Route
path="/firmenleiter"

View File

@@ -1,20 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { ID, Query } from 'appwrite';
import Header from './Header';
import { useToast } from '@/hooks/useToast';
import FilialDetail from './FilialDetail';
import UserCreateForm from './UserCreateForm';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
const ROLE_LABELS = {
admin: 'Admin',
firmenleiter: 'Firmenleiter',
filialleiter: 'Filialleiter',
service: 'Service',
lager: 'Lager',
};
export default function AdminPanel() {
const { showToast } = useToast();
const navigate = useNavigate();
const [stats, setStats] = useState({ users: 0, locations: 0, assets: 0, lagerstandorte: 0, locationsWithoutFilialleiter: 0 });
const [locations, setLocations] = useState([]);
const [users, setUsers] = useState([]);
const [userSearchQuery, setUserSearchQuery] = useState('');
const [showUserForm, setShowUserForm] = useState(false);
const [newFiliale, setNewFiliale] = useState({ name: '', address: '' });
const [addingFiliale, setAddingFiliale] = useState(false);
const [editingId, setEditingId] = useState(null);
@@ -24,11 +39,12 @@ export default function AdminPanel() {
try {
const [locsRes, usersRes, assetsRes, lsRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(200)]),
databases.listDocuments(DATABASE_ID, 'users_meta', [Query.limit(500)]),
databases.listDocuments(DATABASE_ID, 'assets', [Query.limit(1)]),
databases.listDocuments(DATABASE_ID, 'lagerstandorte', [Query.limit(1)]),
]);
setLocations(locsRes.documents);
setUsers(usersRes.documents);
const locationsWithoutFilialleiter = locsRes.documents.filter(
(loc) => !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() {
</div>
</CardContent>
</Card>
<Card className="mt-6">
<CardHeader>
<CardTitle>Benutzer verwaltung</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex gap-2">
<Input
placeholder="Benutzer suchen..."
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
className="max-w-xs"
/>
<Button size="sm" variant="outline" onClick={() => setShowUserForm(true)}>
Neuer Benutzer
</Button>
</div>
<ScrollArea className="h-[280px] rounded-lg border">
<div className="space-y-1 p-2">
{users
.filter((u) =>
(u.userName || u.userId || '')
.toLowerCase()
.includes(userSearchQuery.toLowerCase())
)
.map((u) => (
<button
key={u.$id}
type="button"
onClick={() => navigate(`/admin/user/${u.userId}`)}
className="flex w-full items-center justify-between rounded border px-3 py-2 text-left text-sm hover:bg-muted/50"
>
<span className="font-medium">{u.userName || u.userId}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
{locations.find((l) => l.$id === u.locationId)?.name || 'Nicht zugeordnet'}
</span>
<Badge variant="secondary">{ROLE_LABELS[u.role] || u.role}</Badge>
</div>
</button>
))}
{users.filter((u) =>
(u.userName || u.userId || '').toLowerCase().includes(userSearchQuery.toLowerCase())
).length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
{userSearchQuery ? 'Keine Benutzer gefunden' : 'Keine Benutzer vorhanden'}
</p>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
{showUserForm && (
<UserCreateForm
locations={locations}
onSuccess={() => {
setShowUserForm(false);
loadData();
}}
onCancel={() => setShowUserForm(false)}
showToast={showToast}
/>
)}
</div>
</>
);

View File

@@ -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 (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
{STAT_CARDS.map(({ key, color, label }) => (
<Card key={key} className="py-0" style={{ borderTop: `3px solid ${color}` }}>
<CardContent className="py-5">
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
<p className="text-sm text-muted-foreground mt-1">{label}</p>
</CardContent>
</Card>
))}
const handleCardClick = (key) => {
onStatusFilterChange?.(statusFilter === key ? null : key);
};
{(isAdmin || isFilialleiter) && (
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{STAT_CARDS.map(({ key, color, label }) => {
const isSelected = statusFilter === key;
return (
<Card
className="py-0 cursor-pointer transition-colors hover:bg-muted/50"
style={{ borderTop: '3px solid #F57C00' }}
onClick={() => 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)}
>
<CardContent className="py-5">
<div className="text-3xl font-bold tracking-tight">{lagerstandorte.length}</div>
<p className="text-sm text-muted-foreground mt-1">Lagerstandorte verwalten</p>
<div className="text-3xl font-bold tracking-tight">{counts[key]}</div>
<p className={`text-sm mt-1 ${isSelected ? 'text-foreground/90' : 'text-muted-foreground'}`}>{label}</p>
</CardContent>
</Card>
)}
</div>
{showManager && (
<LagerstandortManager
lagerstandorte={lagerstandorte}
onAdd={onAddLagerstandort}
onToggle={onToggleLagerstandort}
onDelete={onDeleteLagerstandort}
onClose={() => setShowManager(false)}
/>
)}
</>
);
})}
</div>
);
}

View File

@@ -74,10 +74,10 @@ export default function DefektForm({ onAdd, showToast, lagerstandorte, colleague
return (
<Card className="border-0 shadow-none">
<CardHeader className="px-0 pt-0">
<CardHeader className="px-4 pt-0 pb-2">
<CardTitle>Defekte Ware erfassen</CardTitle>
</CardHeader>
<CardContent className="px-0">
<CardContent className="px-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="erlNummer">ERL-Nummer (Logistik) *</Label>

View File

@@ -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 (
<Card className="py-0 gap-0">
<div className="w-full rounded-md border bg-background">
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="text-sm font-medium text-muted-foreground">
{filtered.length} Assets
@@ -219,10 +225,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
<SelectFilter value={filters.standort} onChange={(v) => setFilter('standort', v)} options={standortOptions} />
</ColumnFilter>
<ColumnFilter label="Status" active={activeFilter === 'status'} summary={filters.status ? STATUS_LABEL[filters.status] : null} onOpen={() => openFilter('status')} onClose={closeFilter}>
<SelectFilter value={filters.status} onChange={(v) => setFilter('status', v)} options={STATUS_OPTIONS} />
</ColumnFilter>
<ColumnFilter label="Sortierung" active={activeFilter === 'sort'} summary={sortLabel} onOpen={() => openFilter('sort')} onClose={closeFilter}>
<SelectFilter value={filters.sortBy} onChange={(v) => { setFilter('sortBy', v || 'prio'); closeFilter(); }} options={SORT_OPTIONS} />
</ColumnFilter>
@@ -232,23 +234,26 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
</TableHeader>
<TableBody>
{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 (
<TableRow
key={a.$id}
className={overdue ? 'border-l-2 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20' : ''}
className={rowClassName}
>
<TableCell>
<div className="flex items-center gap-2">
<span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${PRIO_COLORS[a.prio] || ''}`} />
<span className="font-semibold text-blue-700 dark:text-blue-400">{a.erlNummer || ''}</span>
</div>
<TableCell className={`font-semibold p-2 ${PRIO_ERL_CELL[a.prio] || 'bg-muted/50 text-foreground'}`}>
{a.erlNummer || ''}
</TableCell>
<TableCell>
@@ -324,6 +329,6 @@ export default function DefektTable({ assets, onChangeStatus, showToast, lagerst
{commentAsset && (
<CommentPopup artikel={commentAsset} onClose={() => setCommentAsset(null)} />
)}
</Card>
</div>
);
}

View File

@@ -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() {
<div className="p-4">
<Dashboard
assets={assets}
lagerstandorte={lagerstandorte}
onAddLagerstandort={addLagerstandort}
onToggleLagerstandort={toggleLagerstandort}
onDeleteLagerstandort={deleteLagerstandort}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
/>
</div>
@@ -87,6 +86,7 @@ export default function DefektTrackApp() {
onChangeStatus={handleStatusChange}
showToast={showToast}
lagerstandorte={lagerstandorte}
statusFilter={statusFilter}
/>
</div>

View File

@@ -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
<CardTitle className="text-base">Benutzer dieser Filiale</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button size="sm" variant="outline" className="w-full" onClick={() => setShowUserForm(true)}>
Benutzer hinzufügen
</Button>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="flex-1" onClick={() => setShowAssignDialog(true)}>
Benutzer zuordnen
</Button>
<Button size="sm" variant="outline" className="flex-1" onClick={() => setShowUserForm(true)}>
Neuer Benutzer
</Button>
</div>
<div className="max-h-48 space-y-2 overflow-y-auto">
{users.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">Keine Benutzer</p>
@@ -120,6 +127,19 @@ export default function FilialDetail({ location: loc, onClose, showToast, onUser
/>
)}
{showAssignDialog && (
<UserAssignDialog
location={loc}
onClose={() => setShowAssignDialog(false)}
onSuccess={() => {
loadUsers();
setShowAssignDialog(false);
onUserAdded?.();
}}
showToast={showToast}
/>
)}
{showUserForm && (
<UserCreateForm
locationId={loc?.$id}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { Query } from 'appwrite';
import Header from './Header';
@@ -8,6 +10,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
} from '@/components/ui/chart';
function getToday() {
const d = new Date();
@@ -42,7 +51,30 @@ function countInRange(assets, start, end) {
}).length;
}
function countErledigtInRange(assets, start, end) {
return assets.filter((a) => {
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() {
</div>
<div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{todayCount}</div>
<p className="text-sm text-muted-foreground">Heute erfasst</p>
<p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
{dayTrend.arrow} Gestern: {yesterdayCount}
</p>
<Card className="lg:col-span-2">
<CardContent className="pt-4">
<div className="flex items-baseline justify-between gap-2">
<div>
<div className="text-3xl font-bold">{todayCount}</div>
<p className="text-sm text-muted-foreground">Heute erfasst</p>
<p className={`mt-1 text-xs font-medium ${dayTrend.cls}`}>
{dayTrend.arrow} Gestern: {yesterdayCount}
</p>
</div>
</div>
<div className="mt-4 h-[140px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
<BarChart
data={chartData}
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
axisLine={false}
dataKey="day"
tickLine={false}
tickMargin={6}
/>
<YAxis axisLine={false} tickCount={4} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
</BarChart>
</ChartContainer>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-2">
<div className="text-3xl font-bold">{thisMonthCount}</div>
<p className="text-sm text-muted-foreground">Diesen Monat</p>
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
</p>
<Card className="lg:col-span-2">
<CardContent className="pt-4">
<div>
<div className="text-3xl font-bold">{thisMonthCount}</div>
<p className="text-sm text-muted-foreground">Diesen Monat</p>
<p className={`mt-1 text-xs font-medium ${monthTrend.cls}`}>
{monthTrend.arrow} Letzter Monat: {lastMonthCount}
</p>
</div>
<div className="mt-4 h-[140px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full aspect-auto">
<BarChart
data={monthChartData}
margin={{ left: -20, right: 12, top: 4, bottom: 0 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
axisLine={false}
dataKey="month"
tickLine={false}
tickMargin={6}
/>
<YAxis axisLine={false} tickCount={4} tickLine={false} tickMargin={6} tickFormatter={(v) => Math.floor(v)} />
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="erfasst" fill="var(--color-erfasst)" radius={4} />
<Bar dataKey="erledigt" fill="var(--color-erledigt)" radius={4} />
<Bar dataKey="ueberfaellig" fill="var(--color-ueberfaellig)" radius={4} />
</BarChart>
</ChartContainer>
</div>
</CardContent>
</Card>
<Card>
@@ -200,7 +340,11 @@ export default function FilialleiterDashboard() {
</TableHeader>
<TableBody>
{employeeStats.map((e) => (
<TableRow key={e.name}>
<TableRow
key={e.userId || e.name}
className="cursor-pointer hover:bg-muted/50"
onClick={() => e.userId && navigate(`/filialleiter/mitarbeiter/${e.userId}`)}
>
<TableCell className="font-medium">{e.name}</TableCell>
<TableCell className="text-right tabular-nums">{e.total}</TableCell>
<TableCell className="text-right tabular-nums">{e.open}</TableCell>

View File

@@ -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 (
<UserCreateForm
locationId={loc?.$id}
locationName={loc?.name}
onSuccess={() => {
setShowUserForm(false);
loadData();
onSuccess?.();
}}
onCancel={() => setShowUserForm(false)}
showToast={showToast}
/>
);
}
return (
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Benutzer zuordnen</DialogTitle>
<DialogDescription>
Bestehende Benutzer der Filiale {loc?.name} zuordnen oder neu anlegen
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-4 text-center text-sm text-muted-foreground">Laden...</p>
) : (
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => setShowUserForm(true)}>
Neuer Benutzer
</Button>
</div>
{/* Oben: Nicht zugeordnete Benutzer */}
{unassigned.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Noch nicht zugeordnet
</h4>
<ScrollArea className="h-[180px] rounded-lg border">
<div className="space-y-1 p-2">
{unassigned.map((u) => (
<div
key={u.$id}
className="flex items-center justify-between rounded border px-3 py-2 text-sm"
>
<div className="flex flex-1 items-center gap-2">
<span className="font-medium">{u.userName || u.userId}</span>
<span className="text-muted-foreground">
{getLocationName(u.locationId, locations)}
</span>
<Badge variant="secondary">
{ROLE_LABELS[u.role] || u.role}
</Badge>
</div>
<Button
size="sm"
disabled={updatingId === u.$id}
onClick={() => handleAssign(u)}
>
{updatingId === u.$id ? '...' : 'Zuordnen'}
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* Unten: Bereits zugeordnete Benutzer */}
{assignedToThis.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Bereits dieser Filiale zugeordnet
</h4>
<ScrollArea className="h-[180px] rounded-lg border">
<div className="space-y-1 p-2">
{assignedToThis.map((u) => (
<div
key={u.$id}
className="flex items-center justify-between gap-2 rounded border px-3 py-2 text-sm"
>
<div className="flex flex-1 items-center gap-2">
<span className="font-medium">{u.userName || u.userId}</span>
<Badge variant="secondary">
{ROLE_LABELS[u.role] || u.role}
</Badge>
</div>
<div className="flex items-center gap-2">
<Select
value=""
onValueChange={(val) => handleReassign(u, val || '')}
disabled={updatingId === u.$id}
>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="Umordnen..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Nicht zugeordnet</SelectItem>
{locations
.filter((l) => l.$id !== loc?.$id)
.map((l) => (
<SelectItem key={l.$id} value={l.$id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={updatingId === u.$id}
onClick={() => handleUnassign(u)}
>
Entfernen
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{unassigned.length === 0 && assignedToThis.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
Keine Benutzer gefunden. Klicken Sie auf &quot;Neuer Benutzer&quot; um einen anzulegen.
</p>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -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
<DialogHeader>
<DialogTitle>Benutzer hinzufügen</DialogTitle>
<DialogDescription>
Neuer Benutzer für Filiale {locationName || locationId}
{initialLocationId ? `Neuer Benutzer für Filiale ${locationName || initialLocationId}` : 'Neuen Benutzer anlegen und Standort zuweisen'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4" autoComplete="off">
@@ -115,6 +118,23 @@ export default function UserCreateForm({ locationId, locationName, onSuccess, on
className="mt-1"
/>
</div>
{!initialLocationId && locations.length > 0 && (
<div>
<Label>Standort</Label>
<Select value={locationId} onValueChange={setLocationId} required>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Standort wählen..." />
</SelectTrigger>
<SelectContent>
{locations.map((loc) => (
<SelectItem key={loc.$id} value={loc.$id}>
{loc.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label>Rolle</Label>
<Select value={role} onValueChange={setRole}>

View File

@@ -0,0 +1,572 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { databases, DATABASE_ID } from '@/lib/appwrite';
import { Query } from 'appwrite';
import Header from './Header';
import { useToast } from '@/hooks/useToast';
import { useAuth } from '@/context/AuthContext';
import { useAuditLog } from '@/hooks/useAuditLog';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
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 { Input } from '@/components/ui/input';
import { ArrowLeft, Pencil } from 'lucide-react';
const ROLE_LABELS = {
admin: 'Admin',
firmenleiter: 'Firmenleiter',
filialleiter: 'Filialleiter',
service: 'Service',
lager: 'Lager',
};
const TIME_RANGES = [
{ value: '7', label: 'Letzte 7 Tage' },
{ value: '30', label: 'Letzte 30 Tage' },
{ value: '90', label: 'Letzte 3 Monate' },
{ value: 'all', label: 'Alle' },
];
const STATUS_LABELS = {
offen: 'Offen',
in_bearbeitung: 'In Bearbeitung',
erledigt: 'Erledigt',
entsorgt: 'Entsorgt',
};
function formatDate(iso) {
if (!iso) return '-';
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getMonthKey(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
export default function UserDetail() {
const { userId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { role, user: currentUser, userMeta: myMeta } = useAuth();
const isFilialleiterView = location.pathname.startsWith('/filialleiter/mitarbeiter/');
const canAccess = role === 'admin' || role === 'firmenleiter' || (role === 'filialleiter' && isFilialleiterView);
const { showToast } = useToast();
const { logs, loadingLogs, loadLogsByUser, addLog } = useAuditLog();
const [userMeta, setUserMeta] = useState(null);
const [assets, setAssets] = useState([]);
const [locations, setLocations] = useState({});
const [locationsList, setLocationsList] = useState([]);
const [timeRange, setTimeRange] = useState('30');
const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false);
const [editNameValue, setEditNameValue] = useState('');
const [saving, setSaving] = useState(false);
const API_BASE = import.meta.env.VITE_API_URL || '';
if (role && !canAccess) {
return (
<>
<Header showToast={showToast} />
<div className="mx-auto max-w-7xl p-6">
<p className="text-destructive">Keine Berechtigung für diese Seite.</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(isFilialleiterView ? '/filialleiter' : '/admin')}>
Zurück
</Button>
</div>
</>
);
}
const isReadOnly = role === 'filialleiter';
const loadUserMeta = useCallback(async () => {
if (!userId) return;
try {
const res = await databases.listDocuments(DATABASE_ID, 'users_meta', [
Query.equal('userId', [userId]),
Query.limit(1),
]);
setUserMeta(res.documents[0] || null);
return res.documents[0];
} catch (err) {
console.error('User-Meta laden fehlgeschlagen:', err);
return null;
}
}, [userId]);
const loadData = useCallback(async () => {
if (!userId) return;
setLoading(true);
try {
const meta = await loadUserMeta();
if (!meta?.userName) {
setLoading(false);
return;
}
const [assetsRes, locsRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, 'assets', [
Query.equal('zustaendig', [meta.userName]),
Query.limit(500),
]),
databases.listDocuments(DATABASE_ID, 'locations', [Query.limit(100)]),
]);
setAssets(assetsRes.documents);
const locMap = {};
locsRes.documents.forEach((l) => { locMap[l.$id] = l.name; });
setLocations(locMap);
setLocationsList(locsRes.documents);
let startDate = null;
let endDate = null;
if (timeRange !== 'all') {
const days = parseInt(timeRange, 10);
endDate = new Date();
startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate = startDate.toISOString();
endDate = endDate.toISOString();
}
await loadLogsByUser(userId, startDate, endDate);
} catch (err) {
console.error('UserDetail-Daten laden fehlgeschlagen:', err);
showToast?.('Fehler beim Laden', '#C62828');
} finally {
setLoading(false);
}
}, [userId, timeRange, loadUserMeta, loadLogsByUser, showToast]);
useEffect(() => {
loadData();
}, [loadData]);
const editorName = currentUser?.name || currentUser?.email || 'Admin';
async function handleUpdateName(newName) {
const trimmed = (newName || '').trim();
if (!trimmed || trimmed === userMeta?.userName) {
setEditingName(false);
return;
}
setSaving(true);
try {
const res = await fetch(`${API_BASE}/api/admin/update-user`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Admin-Secret': import.meta.env.VITE_ADMIN_SECRET || '',
},
body: JSON.stringify({ userId, userName: trimmed }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showToast?.(data?.error || 'Fehler beim Speichern', '#C62828');
return;
}
setUserMeta((m) => (m ? { ...m, userName: trimmed } : m));
setEditingName(false);
await addLog({
assetId: 'profile',
action: 'name_geaendert',
details: `Name von "${userMeta?.userName}" zu "${trimmed}" geändert (durch ${editorName})`,
userId,
userName: userMeta?.userName,
});
loadData();
showToast?.('Name aktualisiert');
} catch (err) {
showToast?.(err.message || 'Fehler', '#C62828');
} finally {
setSaving(false);
}
}
async function handleUpdateRole(newRole) {
if (!newRole || newRole === userMeta?.role) return;
setSaving(true);
try {
const res = await fetch(`${API_BASE}/api/admin/update-user`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Admin-Secret': import.meta.env.VITE_ADMIN_SECRET || '',
},
body: JSON.stringify({ userId, role: newRole }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showToast?.(data?.error || 'Fehler beim Speichern', '#C62828');
return;
}
const oldRole = userMeta?.role;
setUserMeta((m) => (m ? { ...m, role: newRole } : m));
await addLog({
assetId: 'profile',
action: 'rolle_geaendert',
details: `Rolle von "${ROLE_LABELS[oldRole] || oldRole}" zu "${ROLE_LABELS[newRole] || newRole}" geändert (durch ${editorName})`,
userId,
userName: userMeta?.userName,
});
loadLogsByUser(userId);
showToast?.('Rolle aktualisiert');
} catch (err) {
showToast?.(err.message || 'Fehler', '#C62828');
} finally {
setSaving(false);
}
}
async function handleUpdateLocation(newLocationId) {
if (newLocationId === userMeta?.locationId) return;
setSaving(true);
const oldName = locations[userMeta?.locationId || ''] || 'Nicht zugeordnet';
const newName = newLocationId ? (locations[newLocationId] || newLocationId) : 'Nicht zugeordnet';
try {
const res = await fetch(`${API_BASE}/api/admin/update-user`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Admin-Secret': import.meta.env.VITE_ADMIN_SECRET || '',
},
body: JSON.stringify({ userId, locationId: newLocationId || '' }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showToast?.(data?.error || 'Fehler beim Speichern', '#C62828');
return;
}
setUserMeta((m) => (m ? { ...m, locationId: newLocationId || '' } : m));
await addLog({
assetId: 'profile',
action: 'filiale_geaendert',
details: `Filiale von "${oldName}" zu "${newName}" geändert (durch ${editorName})`,
userId,
userName: userMeta?.userName,
});
loadLogsByUser(userId);
loadData();
showToast?.('Filiale aktualisiert');
} catch (err) {
showToast?.(err.message || 'Fehler', '#C62828');
} finally {
setSaving(false);
}
}
const byStatus = useMemo(() => {
const groups = { offen: [], in_bearbeitung: [], erledigt: [], entsorgt: [] };
assets.forEach((a) => {
const k = a.status || 'offen';
if (groups[k]) groups[k].push(a);
else groups.erledigt.push(a);
});
return groups;
}, [assets]);
const monthlyStats = useMemo(() => {
const map = {};
logs.forEach((log) => {
const d = new Date(log.$createdAt);
const key = getMonthKey(d);
if (!map[key]) map[key] = { actions: 0 };
map[key].actions += 1;
});
assets.forEach((a) => {
if (a.status === 'erledigt' || a.status === 'entsorgt') {
const d = new Date(a.$updatedAt || a.$createdAt);
const key = getMonthKey(d);
if (!map[key]) map[key] = { actions: 0, completed: 0 };
map[key].completed = (map[key].completed || 0) + 1;
}
});
return Object.entries(map)
.sort(([a], [b]) => b.localeCompare(a))
.slice(0, 6)
.map(([month, data]) => ({ month, ...data }));
}, [logs, assets]);
if (loading && !userMeta) {
return (
<>
<Header showToast={showToast} />
<div className="mx-auto max-w-7xl p-6">
<Button variant="ghost" size="sm" onClick={() => navigate('/admin')} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<p className="py-8 text-center text-muted-foreground">Laden...</p>
</div>
</>
);
}
const backPath = isFilialleiterView ? '/filialleiter' : '/admin';
if (!userMeta) {
return (
<>
<Header showToast={showToast} />
<div className="mx-auto max-w-7xl p-6">
<Button variant="ghost" size="sm" onClick={() => navigate(backPath)} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<p className="text-muted-foreground">Benutzer nicht gefunden.</p>
</div>
</>
);
}
if (isReadOnly && userMeta.locationId !== myMeta?.locationId) {
return (
<>
<Header showToast={showToast} />
<div className="mx-auto max-w-7xl p-6">
<Button variant="ghost" size="sm" onClick={() => navigate(backPath)} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<p className="text-destructive">Dieser Mitarbeiter gehört nicht zu deiner Filiale.</p>
</div>
</>
);
}
return (
<>
<Header showToast={showToast} />
<div className="mx-auto max-w-7xl p-6">
<Button variant="ghost" size="sm" onClick={() => navigate(backPath)} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<div className="mb-8">
<div className="flex items-center gap-2">
{editingName && !isReadOnly ? (
<>
<Input
value={editNameValue}
onChange={(e) => 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
/>
<Button size="sm" variant="ghost" onClick={() => { setEditingName(false); setEditNameValue(''); }}>
Abbrechen
</Button>
</>
) : (
<>
<h1 className="text-3xl font-bold tracking-tight">
{userMeta?.userName || userId}
</h1>
{!isReadOnly && (
<Button
variant="ghost"
size="icon-sm"
className="h-8 w-8 shrink-0"
onClick={() => { setEditNameValue(userMeta?.userName || ''); setEditingName(true); }}
disabled={saving}
title="Name bearbeiten"
>
<Pencil className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
{!isReadOnly && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Select
value={userMeta?.role || ''}
onValueChange={handleUpdateRole}
disabled={saving}
>
<SelectTrigger className="h-8 w-auto min-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ROLE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={userMeta?.locationId || '_none'}
onValueChange={(v) => handleUpdateLocation(v === '_none' ? '' : v)}
disabled={saving}
>
<SelectTrigger className="h-8 w-auto min-w-[160px]">
<SelectValue placeholder="Standort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Nicht zugeordnet</SelectItem>
{locationsList.map((loc) => (
<SelectItem key={loc.$id} value={loc.$id}>{loc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Audit-Logs */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Benutzer-Logs</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{loadingLogs ? (
<p className="py-4 text-center text-sm text-muted-foreground">Laden...</p>
) : logs.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Keine Logs im gewählten Zeitraum
</p>
) : (
<ScrollArea className="h-[280px]">
<div className="space-y-2">
{logs.map((log) => (
<div
key={log.$id}
className="rounded border px-3 py-2 text-sm"
>
<div className="flex items-center justify-between gap-2">
<Badge variant="outline">{log.action}</Badge>
<span className="text-muted-foreground">
{formatDate(log.$createdAt)}
</span>
</div>
{log.details && (
<p className="mt-1 text-muted-foreground">{log.details}</p>
)}
{log.assetId && log.assetId !== 'profile' && (
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => navigate(`/asset/${log.assetId}`)}
>
Asset öffnen
</Button>
)}
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Aufgaben */}
<Card>
<CardHeader>
<CardTitle>Aufgaben (zugeordnete Assets)</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{['offen', 'in_bearbeitung', 'entsorgt', 'erledigt'].map((status) => (
<div key={status}>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
{STATUS_LABELS[status]} ({byStatus[status]?.length || 0})
</h4>
<ScrollArea className="h-[120px] rounded border">
<div className="space-y-1 p-2">
{(byStatus[status] || []).map((a) => (
<div
key={a.$id}
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
<span className="truncate">{a.erlNummer || a.bezeichnung || a.$id}</span>
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => navigate(`/asset/${a.$id}`)}
>
Öffnen
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Stats / Monatsvergleich */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Monats-Statistik</CardTitle>
</CardHeader>
<CardContent>
{monthlyStats.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Noch keine auswertbaren Daten
</p>
) : (
<div className="flex flex-wrap gap-4">
{monthlyStats.map(({ month, actions = 0, completed = 0 }) => (
<div
key={month}
className="flex flex-col gap-2 rounded-lg border p-4 min-w-[140px]"
>
<span className="font-medium">{month}</span>
<div className="text-2xl font-bold">{actions} Aktionen</div>
<div className="text-sm text-muted-foreground">
{completed} abgeschlossen
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary"
style={{
width: `${Math.min(100, (completed / Math.max(actions, 1)) * 100)}%`,
}}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}

312
src/components/ui/chart.jsx Normal file
View File

@@ -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 <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({
id,
config
}) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`)
.join("\n"),
}} />
);
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor
}
} />
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}} />
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(
config,
payload,
key
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey = key
if (
key in payload &&
typeof payload[key] === "string"
) {
configLabelKey = payload[key]
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key] === "string"
) {
configLabelKey = payloadPayload[key]
}
return configLabelKey in config
? config[configLabelKey]
: config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -5,23 +5,40 @@ import { ID, Query } from 'appwrite';
const COLLECTION = 'assets';
const NEXT_STATUS = { offen: 'in_bearbeitung', in_bearbeitung: 'entsorgt', entsorgt: 'offen' };
export function useAssets() {
export function useAssets(locationId = '') {
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
const loadAssets = useCallback(async () => {
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTION, [
Query.orderDesc('$createdAt'),
Query.limit(500),
]);
setAssets(res.documents);
let allDocs = [];
if (locationId) {
const [assetsRes, lagerRes] = await Promise.all([
databases.listDocuments(DATABASE_ID, COLLECTION, [
Query.orderDesc('$createdAt'),
Query.limit(500),
]),
databases.listDocuments(DATABASE_ID, 'lagerstandorte', [
Query.equal('locationId', [locationId]),
Query.limit(200),
]),
]);
const lagerIds = new Set(lagerRes.documents.map((l) => l.$id));
allDocs = assetsRes.documents.filter((a) => a.lagerstandortId && lagerIds.has(a.lagerstandortId));
} else {
const res = await databases.listDocuments(DATABASE_ID, COLLECTION, [
Query.orderDesc('$createdAt'),
Query.limit(500),
]);
allDocs = res.documents;
}
setAssets(allDocs);
} catch (err) {
console.error('Assets laden fehlgeschlagen:', err);
} finally {
setLoading(false);
}
}, []);
}, [locationId]);
useEffect(() => { loadAssets(); }, [loadAssets]);

View File

@@ -25,6 +25,30 @@ export function useAuditLog() {
}
}, []);
const loadLogsByUser = useCallback(async (userId, startDate, endDate) => {
setLoadingLogs(true);
try {
const queries = [
Query.equal('userId', [userId]),
Query.orderDesc('$createdAt'),
Query.limit(200),
];
if (startDate) {
queries.push(Query.greaterThanEqual('$createdAt', startDate));
}
if (endDate) {
queries.push(Query.lessThanEqual('$createdAt', endDate));
}
const res = await databases.listDocuments(DATABASE_ID, COLLECTION, queries);
setLogs(res.documents);
} catch (err) {
console.error('User-Audit-Logs laden fehlgeschlagen:', err);
setLogs([]);
} finally {
setLoadingLogs(false);
}
}, []);
const addLog = useCallback(async ({ assetId, action, details, userId, userName }) => {
try {
const doc = await databases.createDocument(DATABASE_ID, COLLECTION, ID.unique(), {
@@ -41,5 +65,5 @@ export function useAuditLog() {
}
}, []);
return { logs, loadingLogs, loadLogs, addLog };
return { logs, loadingLogs, loadLogs, loadLogsByUser, addLog };
}