fieles neues
This commit is contained in:
313
package-lock.json
generated
313
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
271
scripts/seed-dummy-data.js
Normal 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);
|
||||
});
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
17
src/App.jsx
17
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() {
|
||||
</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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
256
src/components/UserAssignDialog.jsx
Normal file
256
src/components/UserAssignDialog.jsx
Normal 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 "Neuer Benutzer" um einen anzulegen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
572
src/components/UserDetail.jsx
Normal file
572
src/components/UserDetail.jsx
Normal 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
312
src/components/ui/chart.jsx
Normal 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,
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user