diff --git a/backend/package-lock.json b/backend/package-lock.json index f6f5c2a..fe9839f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -33,6 +33,7 @@ "pg": "^8.14.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sharp": "^0.34.2", "typeorm": "^0.3.20", "uuid": "^11.1.0", "winston": "^3.17.0" @@ -770,6 +771,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -990,6 +1001,402 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz", + "integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz", + "integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz", + "integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz", + "integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz", + "integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz", + "integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz", + "integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz", + "integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz", + "integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz", + "integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz", + "integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz", + "integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.2.tgz", @@ -5851,6 +6258,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -11022,9 +11438,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11181,6 +11597,60 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz", + "integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.4", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.2", + "@img/sharp-darwin-x64": "0.34.2", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.2", + "@img/sharp-linux-arm64": "0.34.2", + "@img/sharp-linux-s390x": "0.34.2", + "@img/sharp-linux-x64": "0.34.2", + "@img/sharp-linuxmusl-arm64": "0.34.2", + "@img/sharp-linuxmusl-x64": "0.34.2", + "@img/sharp-wasm32": "0.34.2", + "@img/sharp-win32-arm64": "0.34.2", + "@img/sharp-win32-ia32": "0.34.2", + "@img/sharp-win32-x64": "0.34.2" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 61e21fc..9418516 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,6 +49,7 @@ "pg": "^8.14.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sharp": "^0.34.2", "typeorm": "^0.3.20", "uuid": "^11.1.0", "winston": "^3.17.0" diff --git a/backend/src/base/location/location.entity.ts b/backend/src/base/location/location.entity.ts index 5708c2a..8d6a5d1 100644 --- a/backend/src/base/location/location.entity.ts +++ b/backend/src/base/location/location.entity.ts @@ -8,6 +8,7 @@ import { TreeParent, } from 'typeorm'; import { DeviceEntity } from '../../inventory/device/device.entity'; +import { ConsumableLocationEntity } from '../../inventory/consumable/consumable-location.entity'; export enum LocationType { NONE = 0, // Keine Angabe @@ -57,4 +58,7 @@ export class LocationEntity { @OneToMany(() => DeviceEntity, (x) => x.location) devices: DeviceEntity[]; + + @OneToMany(() => ConsumableLocationEntity, (x) => x.location) + consumableLocations: ConsumableLocationEntity[]; } diff --git a/backend/src/base/location/location.module.ts b/backend/src/base/location/location.module.ts index 14de00c..6f640f1 100644 --- a/backend/src/base/location/location.module.ts +++ b/backend/src/base/location/location.module.ts @@ -9,5 +9,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; imports: [TypeOrmModule.forFeature([LocationEntity])], controllers: [LocationController], providers: [LocationService, LocationDbService], + exports: [LocationDbService], }) export class LocationModule {} diff --git a/backend/src/core/auth/role/role.ts b/backend/src/core/auth/role/role.ts index 7cacbdd..b1d47ad 100644 --- a/backend/src/core/auth/role/role.ts +++ b/backend/src/core/auth/role/role.ts @@ -37,6 +37,26 @@ export class Role { [Role.LocationView], ); + public static readonly ConsumableGroupView = new Role( + 'consumable-group.view', + 'Verbrauchsgüter-Gruppe ansehen', + ); + public static readonly ConsumableGroupManage = new Role( + 'consumable-group.manage', + 'Verbrauchsgüter-Gruppe verwalten', + [Role.ConsumableGroupView], + ); + public static readonly ConsumableView = new Role( + 'consumable.view', + 'Verbrauchsgüter ansehen', + [Role.ConsumableGroupView, Role.LocationView], + ); + public static readonly ConsumableManage = new Role( + 'consumable.manage', + 'Verbrauchsgüter verwalten', + [Role.ConsumableView], + ); + public static readonly UserView = new Role('user.view', 'Benutzer ansehen'); public static readonly UserManage = new Role( 'user.manage', @@ -58,6 +78,8 @@ export class Role { Role.DeviceTypeManage, Role.DeviceGroupManage, Role.DeviceManage, + Role.ConsumableGroupManage, + Role.ConsumableManage, ]); public name: string; diff --git a/backend/src/core/configuration.ts b/backend/src/core/configuration.ts index de10310..1a2f42c 100644 --- a/backend/src/core/configuration.ts +++ b/backend/src/core/configuration.ts @@ -10,12 +10,14 @@ import { validateSync, } from 'class-validator'; import { Logger } from '@nestjs/common'; +import * as process from 'node:process'; export enum ConfigKey { App = 'APP', Db = 'DB', Minio = 'MINIO', Keycloak = 'KEYCLOAK', + Amqp = 'AMQP', } const AppConfig = registerAs(ConfigKey.App, () => ({ @@ -30,14 +32,23 @@ const DbConfig = registerAs(ConfigKey.Db, () => ({ database: process.env.DATABASE, })); +const AmqpConfig = registerAs(ConfigKey.Amqp, () => ({ + host: process.env.AMQP_HOST, + vhost: process.env.AMQP_VHOST, + port: Number(process.env.AMQP_PORT), + username: process.env.AMQP_USERNAME, + password: process.env.AMQP_PASSWORD, + queueFileChange: process.env.AMQP_QUEUE_FILE_CHANGE, +})); + const MinioConfig = registerAs(ConfigKey.Minio, () => ({ host: process.env.MINIO_HOST, port: Number(process.env.MINIO_PORT), useSsl: process.env.MINIO_USE_SSL === 'true', accessKey: process.env.MINIO_ACCESS_KEY, secretKey: process.env.MINIO_SECRET_KEY, - uploadExpiry: process.env.MINIO_UPLOAD_EXPIRY, - downloadExpiry: process.env.MINIO_DOWNLOAD_EXPIRY, + uploadExpiry: Number(process.env.MINIO_UPLOAD_EXPIRY), + downloadExpiry: Number(process.env.MINIO_DOWNLOAD_EXPIRY), bucketName: process.env.MINIO_BUCKET_NAME, })); @@ -55,6 +66,7 @@ export const configurations = [ DbConfig, MinioConfig, KeycloakConfig, + AmqpConfig, ]; class EnvironmentVariables { @@ -161,6 +173,37 @@ class EnvironmentVariables { @IsString() @MinLength(1) KEYCLOAK_ISSUER: string; + + /* AMQP CONFIG */ + @IsDefined() + @IsString() + @MinLength(1) + AMQP_HOST: string; + + @IsDefined() + @IsString() + @MinLength(1) + AMQP_VHOST: string; + + @IsDefined() + @IsNumberString() + @MinLength(1) + AMQP_PORT: string; + + @IsDefined() + @IsString() + @MinLength(1) + AMQP_USERNAME: string; + + @IsDefined() + @IsString() + @MinLength(1) + AMQP_PASSWORD: string; + + @IsDefined() + @IsString() + @MinLength(1) + AMQP_QUEUE_FILE_CHANGE: string; } export function validateConfig(configuration: Record) { diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index 118f351..384c468 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -8,7 +8,7 @@ import { import { ConfigModule, ConfigService } from '@nestjs/config'; import { configurations, validateConfig } from './configuration'; import { StartupService } from './services/startup.service'; -import { MinioService } from './services/minio.service'; +import { MinioService } from './services/storage/minio.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { KeycloakService } from './services/keycloak.service'; import { HttpModule } from '@nestjs/axios'; @@ -28,6 +28,14 @@ import { LoggerContextMiddleware } from './middleware/logger-context.middleware' import { DeviceTypeEntity } from '../inventory/device-type/device-type.entity'; import { DeviceGroupEntity } from '../inventory/device-group/device-group.entity'; import { DeviceEntity } from '../inventory/device/device.entity'; +import { ConsumableEntity } from '../inventory/consumable/consumable.entity'; +import { ConsumableGroupEntity } from '../inventory/consumable-group/consumable-group.entity'; +import { ConsumableLocationEntity } from '../inventory/consumable/consumable-location.entity'; +import { AmqpService } from './services/amqp.service'; +import { MinioListenerService } from './services/storage/minio-listener.service'; +import { ImageService } from './services/storage/image.service'; +import { DeviceImageEntity } from '../inventory/device/device-image.entity'; +import { InventoryModule } from '../inventory/inventory.module'; @Module({ imports: [ @@ -61,6 +69,10 @@ import { DeviceEntity } from '../inventory/device/device.entity'; DeviceTypeEntity, DeviceGroupEntity, DeviceEntity, + ConsumableEntity, + ConsumableGroupEntity, + ConsumableLocationEntity, + DeviceImageEntity, ], extra: { connectionLimit: 10, @@ -71,6 +83,7 @@ import { DeviceEntity } from '../inventory/device/device.entity'; // migrationsRun: true, }), }), + InventoryModule, ], providers: [ StartupService, @@ -83,8 +96,16 @@ import { DeviceEntity } from '../inventory/device/device.entity'; RequestContextService, AppLoggerService, LoggerContextMiddleware, + AmqpService, + MinioListenerService, + ImageService, + ], + exports: [ + MinioService, + KeycloakService, + LoggerContextMiddleware, + AmqpService, ], - exports: [MinioService, KeycloakService, LoggerContextMiddleware], controllers: [UserController, GroupController], }) @Global() diff --git a/backend/src/core/services/amqp.service.spec.ts b/backend/src/core/services/amqp.service.spec.ts new file mode 100644 index 0000000..fb8c82e --- /dev/null +++ b/backend/src/core/services/amqp.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AmqpService } from './amqp.service'; + +describe('AmqpService', () => { + let service: AmqpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AmqpService], + }).compile(); + + service = module.get(AmqpService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/core/services/amqp.service.ts b/backend/src/core/services/amqp.service.ts new file mode 100644 index 0000000..c49b3c9 --- /dev/null +++ b/backend/src/core/services/amqp.service.ts @@ -0,0 +1,189 @@ +import { Injectable, Logger, OnModuleDestroy, Scope } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + AMQPChannel, + AMQPClient, + AMQPConsumer, + AMQPMessage, +} from '@cloudamqp/amqp-client'; + +@Injectable({ scope: Scope.DEFAULT }) +export class AmqpService implements OnModuleDestroy { + /** + * Exchanges, die in der Anwendung verwendet werden + * Die Reihenfolge ist wichtig, da die Exchanges in dieser Reihenfolge erstellt werden. + * @private + */ + private readonly exchanges: { + [key: string]: { + type: string; + name: string; + durable: boolean; + bindings: { exchange: string; routing: string }[]; + }; + } = { + data: { + type: 'fanout', + name: 'data', + durable: true, + bindings: [], + }, // Für Datenänderungen in der Datenbanke (Aktualisierung der Search, ...) + file: { + type: 'fanout', + name: 'file', + durable: true, + bindings: [], + }, // Notifications von Minio + }; + + /** + * Die Queues, die in der Anwendung verwendet werden + * Die Reihenfolge ist wichtig, da die Queues in dieser Reihenfolge erstellt werden. + * @private + */ + private readonly queues: { + [key: string]: { + durable: boolean; + name: string; + bindings?: { exchange: string; routing: string }[]; + }; + } = { + data: { + name: 'data', + durable: true, + bindings: [{ exchange: this.exchanges.data.name, routing: '' }], + }, + file: { + name: 'file', + durable: true, + bindings: [{ exchange: this.exchanges.file.name, routing: '' }], + }, + }; + + private client: AMQPClient; + private channel: AMQPChannel; + + private consumers: AMQPConsumer[] = []; + + constructor( + private readonly configService: ConfigService, + private readonly logger: Logger, + ) { + const host = configService.get('AMQP.host'); // TODO hier gehts weiter + const vhost = configService.get('AMQP.vhost') ?? ''; + const port = configService.get('AMQP.port'); + const username = encodeURIComponent( + configService.get('AMQP.username')!, + ); + const password = encodeURIComponent( + configService.get('AMQP.password')!, + ); + const amqpUrl = `amqp://${username}:${password}@${host}:${port}${vhost}`; + this.client = new AMQPClient(amqpUrl); + } + + /** + * Verbinde dich mit dem AMQP-Server und Umgebung einrichten + */ + async setupEnvironment(): Promise { + this.logger.debug('AMQP-Setup gestartet'); + await this.client.connect(); + this.channel = await this.client.channel(); + const channel = await this.client.channel(); + + // Exchanges erstellen + for (const ex of Object.keys(this.exchanges)) { + const exchange = this.exchanges[ex]; + await channel.exchangeDeclare(exchange.name, exchange.type, { + durable: exchange.durable, + autoDelete: false, + internal: false, + passive: false, + }); + this.logger.debug(`AMQP-Exchange ${exchange.name} erstellt.`); + if (exchange.bindings) { + for (const binding of exchange.bindings) { + await channel.exchangeBind( + exchange.name, + binding.exchange, + binding.routing, + ); + this.logger.debug( + `AMQP-Exchange ${exchange.name} an ${binding.exchange} mir Routing-Key '${binding.routing}' gebunden.`, + ); + } + } + } + + // Queues erstellen + for (const qu of Object.keys(this.queues)) { + const queue = this.queues[qu]; + await channel.queueDeclare(queue.name, { + durable: queue.durable, + autoDelete: false, + passive: false, + exclusive: false, + }); + this.logger.debug(`AMQP-Queue ${queue.name} erstellt.`); + if (queue.bindings) { + for (const binding of queue.bindings) { + await channel.queueBind( + queue.name, + binding.exchange, + binding.routing, + ); + this.logger.debug( + `AMQP-Queue ${queue.name} an ${binding.exchange} mir Routing-Key '${binding.routing}' gebunden.`, + ); + } + } + } + + await channel.close(); + this.logger.debug('AMQP-Setup abgeschlossen'); + } + + async registerConsumer( + queue: string, + injectedServices: K, + callback: (routing: string, data: T, services: K) => Promise, + ) { + const consumer = await this.channel.basicConsume( + queue, + {}, + (message: AMQPMessage) => { + try { + const data = JSON.parse(message.bodyToString()!) as T; + void callback(message.routingKey, data, injectedServices); + } catch (e) { + this.logger.error(e); + } + }, + ); + this.consumers.push(consumer); + } + + /** + * Veröffentliche eine Nachricht, dass sich ein Daten-Element geändert hat. + * @param topic + * @param data + */ + async publishDataChange(topic: string, data: any): Promise { + try { + await this.channel.basicPublish( + this.exchanges.data.name, + topic, + JSON.stringify(data), + ); + this.logger.debug(`Data-Change mit Topic'${topic}' veröffentlicht.`); + } catch (e) { + this.logger.error(e); + } + } + + async onModuleDestroy(): Promise { + for (const consumer of this.consumers) { + await consumer.cancel(); + } + } +} diff --git a/backend/src/core/services/minio.service.ts b/backend/src/core/services/minio.service.ts deleted file mode 100644 index bee5bb4..0000000 --- a/backend/src/core/services/minio.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Client } from 'minio'; - -@Injectable() -export class MinioService { - private readonly allowedImageTypes = [ - 'image/jpeg', - 'image/png', - 'image/tiff', - 'image/bmp', - 'image/heic', - ]; - - private client: Client; - - private readonly uploadExpiry: number; - private readonly downloadExpiry: number; - - private readonly bucketName: string; - - constructor( - private readonly configService: ConfigService, - private readonly logger: Logger, - ) { - const accessKey = this.configService.get('MINIO.accessKey'); - const secretKey = this.configService.get('MINIO.secretKey'); - const useSSL = this.configService.get('MINIO.useSsl'); - const endPoint = this.configService.get('MINIO.host')!; - const port = this.configService.get('MINIO.port'); - this.bucketName = this.configService.get('MINIO.bucketName')!; - - this.uploadExpiry = this.configService.get('MINIO.uploadExpiry')!; - this.downloadExpiry = this.configService.get( - 'MINIO.downloadExpiry', - )!; - - this.client = new Client({ - endPoint, - accessKey, - secretKey, - useSSL, - port, - }); - } - - async setupEnvironment() { - this.logger.debug('Minio-Setup gestartet'); - - // Bucket erstellen - if (!(await this.client.bucketExists(this.bucketName))) { - await this.client.makeBucket(this.bucketName); - this.logger.debug('Minio Bucket erstellt'); - } - - this.logger.debug('Minio-Setup abgeschlossen'); - } -} diff --git a/backend/src/core/services/request-context.service.ts b/backend/src/core/services/request-context.service.ts index 971c8d3..5f40aa2 100644 --- a/backend/src/core/services/request-context.service.ts +++ b/backend/src/core/services/request-context.service.ts @@ -5,12 +5,12 @@ import { AsyncLocalStorage } from 'async_hooks'; export class RequestContextService { private readonly als = new AsyncLocalStorage>(); - run(callback: () => void, context: Record) { + run(callback: () => void, context: Record) { const store = new Map(Object.entries(context)); this.als.run(store, callback); } - set(key: string, value: any) { + set(key: string, value: string) { const store = this.als.getStore(); if (store) { store.set(key, value); diff --git a/backend/src/core/services/startup.service.spec.ts b/backend/src/core/services/startup.service.spec.ts index deb3a16..cdcaa2c 100644 --- a/backend/src/core/services/startup.service.spec.ts +++ b/backend/src/core/services/startup.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StartupService } from './startup.service'; -import { MinioService } from './minio.service'; +import { MinioService } from './storage/minio.service'; import { KeycloakService } from './keycloak.service'; import { Logger } from '@nestjs/common'; diff --git a/backend/src/core/services/startup.service.ts b/backend/src/core/services/startup.service.ts index 604132c..62dcc8e 100644 --- a/backend/src/core/services/startup.service.ts +++ b/backend/src/core/services/startup.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; -import { MinioService } from './minio.service'; +import { MinioService } from './storage/minio.service'; import { KeycloakService } from './keycloak.service'; +import { AmqpService } from './amqp.service'; /** * Der StartupService wird beim Starten der Anwendung ausgeführt und initialisiert die Umgebung. @@ -10,6 +11,7 @@ export class StartupService { constructor( private readonly minioService: MinioService, private readonly keycloakService: KeycloakService, + private readonly amqpService: AmqpService, private readonly logger: Logger, ) {} @@ -18,6 +20,7 @@ export class StartupService { */ async init() { this.logger.debug('Startup-Setup gestartet'); + await this.amqpService.setupEnvironment(); await this.minioService.setupEnvironment(); await this.keycloakService.setupEnvironment(); this.logger.debug('Startup-Setup abgeschlossen'); diff --git a/backend/src/core/services/storage/dto/minio-listener/bucket-event.dto.ts b/backend/src/core/services/storage/dto/minio-listener/bucket-event.dto.ts new file mode 100644 index 0000000..06117ef --- /dev/null +++ b/backend/src/core/services/storage/dto/minio-listener/bucket-event.dto.ts @@ -0,0 +1,36 @@ +export interface BucketEventRecordS3Bucket { + name: string; + arn: string; +} + +export interface BucketEventRecordS3Object { + key: string; + size: number; + eTag: string; + contentType: string; + userMetadata: { + 'content-type': string; + }; +} + +export interface BucketEventRecordS3 { + s3SchemaVersion: string; + configurationId: string; + bucket: BucketEventRecordS3Bucket; + object: BucketEventRecordS3Object; +} + +export interface BucketEventRecord { + eventVersion: string; + eventSource: string; + awsRegion: string; + eventTime: Date; + eventName: string; + s3: BucketEventRecordS3; +} + +export interface BucketEventDto { + EventName: string; + Key: string; + Records: BucketEventRecord[]; +} diff --git a/backend/src/core/services/storage/image.service.spec.ts b/backend/src/core/services/storage/image.service.spec.ts new file mode 100644 index 0000000..2adc452 --- /dev/null +++ b/backend/src/core/services/storage/image.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ImageService } from './image.service'; + +describe('ImageService', () => { + let service: ImageService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ImageService], + }).compile(); + + service = module.get(ImageService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/core/services/storage/image.service.ts b/backend/src/core/services/storage/image.service.ts new file mode 100644 index 0000000..853ef36 --- /dev/null +++ b/backend/src/core/services/storage/image.service.ts @@ -0,0 +1,81 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { MinioService } from './minio.service'; +import * as sharp from 'sharp'; +import { Sharp } from 'sharp'; +import { DeviceService } from '../../../inventory/device/device.service'; + +@Injectable() +export class ImageService { + static readonly sizes = [200, 480, 800, 1200, 1600, 2000, 4000]; + static readonly blurredSizes = [600]; + static readonly previewSuffix = '-webp-480'; + + constructor( + @Inject(forwardRef(() => MinioService)) + private readonly minioService: MinioService, + @Inject(forwardRef(() => DeviceService)) + private readonly deivceService: DeviceService, + ) {} + + public async processDevice(key: string) { + try { + const img = await this.loadImage(key); + await this.convertImage(key, img); + await this.generateSizes(key, img); + await this.blurredImage(key, img); + + const parts = key.split('/'); + + await this.deivceService.addImage(Number(parts[1]), parts[3]); + } catch (error) { + console.log(error); + // TODO delete image + } + } + + private async convertImage(key: string, img: Sharp) { + // in WebP umwandeln + const buffer = await img.webp({ quality: 90 }).toBuffer(); + + // speichern + await this.minioService.putObject(key + '-webp', buffer, { + 'Content-Type': 'image/webp', + }); + } + + private async generateSizes(key: string, img: Sharp) { + for (const size of ImageService.sizes) { + const buffer = await img.resize(size, null, { fit: 'inside' }).toBuffer(); + + await this.minioService.putObject(key + '-webp-' + size, buffer, { + 'Content-Type': 'image/webp', + }); + } + } + + private async blurredImage(key: string, img: Sharp) { + for (const size of ImageService.blurredSizes) { + const buffer = await img + .resize(size, null, { fit: 'inside' }) + .blur(40) + .toBuffer(); + + await this.minioService.putObject( + key + '-webp-' + size + '-blur', + buffer, + { + 'Content-Type': 'image/webp', + }, + ); + } + } + + private async loadImage(key: string): Promise { + const stream = await this.minioService.getObject(key); + const chunks: Uint8Array[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return sharp(Buffer.concat(chunks)); + } +} diff --git a/backend/src/core/services/storage/minio-listener.service.spec.ts b/backend/src/core/services/storage/minio-listener.service.spec.ts new file mode 100644 index 0000000..d3e3bf5 --- /dev/null +++ b/backend/src/core/services/storage/minio-listener.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MinioListenerService } from './minio-listener.service'; + +describe('MinioListenerService', () => { + let service: MinioListenerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MinioListenerService], + }).compile(); + + service = module.get(MinioListenerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/core/services/storage/minio-listener.service.ts b/backend/src/core/services/storage/minio-listener.service.ts new file mode 100644 index 0000000..40ad545 --- /dev/null +++ b/backend/src/core/services/storage/minio-listener.service.ts @@ -0,0 +1,72 @@ +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AmqpService } from '../amqp.service'; +import { + BucketEventDto, + BucketEventRecordS3Object, +} from './dto/minio-listener/bucket-event.dto'; +import { ImageService } from './image.service'; + +interface InjectedServices { + imageService: ImageService; +} + +@Injectable() +export class MinioListenerService implements OnApplicationBootstrap { + static readonly imageRegex = + /\/[0-9]+\/images\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + static readonly docRegex = + /\/[0-9]+\/docs\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + + constructor( + private readonly queueService: AmqpService, + private readonly configService: ConfigService, + private readonly imageService: ImageService, + ) {} + + async onApplicationBootstrap(): Promise { + const queueName = this.configService.get('AMQP.queueFileChange')!; + await this.queueService.registerConsumer( + queueName, + { + imageService: this.imageService, + }, + this.onEvent, + ); + } + + async onEvent( + routingKey: string, + message: BucketEventDto, + services: InjectedServices, + ): Promise { + if (message.Records.length != 1 && !message.Records[0].s3.object.key) { + return; + } + + switch (message.EventName) { + case 's3:ObjectCreated:Post': + await MinioListenerService.handleCreated( + message.Records[0].s3.object, + services, + ); + break; + // TODO handle update event + } + } + + private static async handleCreated( + bucketObject: BucketEventRecordS3Object, + services: InjectedServices, + ): Promise { + const key = decodeURIComponent(bucketObject.key); + const imageType = this.imageRegex.test(key); + // const docType = this.docRegex.test(key); + + if (key.startsWith('devices/')) { + if (imageType) { + await services.imageService.processDevice(key); + } + } + } +} diff --git a/backend/src/core/services/minio.service.spec.ts b/backend/src/core/services/storage/minio.service.spec.ts similarity index 100% rename from backend/src/core/services/minio.service.spec.ts rename to backend/src/core/services/storage/minio.service.spec.ts diff --git a/backend/src/core/services/storage/minio.service.ts b/backend/src/core/services/storage/minio.service.ts new file mode 100644 index 0000000..3b77b29 --- /dev/null +++ b/backend/src/core/services/storage/minio.service.ts @@ -0,0 +1,136 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Client, ItemBucketMetadata } from 'minio'; +import { ImageService } from './image.service'; + +@Injectable() +export class MinioService { + private readonly allowedImageTypes = [ + 'image/jpeg', + 'image/png', + 'image/tiff', + ]; + + private client: Client; + + private readonly uploadExpiry: number; + private readonly downloadExpiry: number; + + private readonly bucketName: string; + + constructor( + private readonly configService: ConfigService, + private readonly logger: Logger, + @Inject(forwardRef(() => ImageService)) + private readonly imageService: ImageService, + ) { + const accessKey = this.configService.get('MINIO.accessKey'); + const secretKey = this.configService.get('MINIO.secretKey'); + const useSSL = this.configService.get('MINIO.useSsl'); + const endPoint = this.configService.get('MINIO.host')!; + const port = this.configService.get('MINIO.port'); + this.bucketName = this.configService.get('MINIO.bucketName')!; + + this.uploadExpiry = this.configService.get('MINIO.uploadExpiry')!; + this.downloadExpiry = this.configService.get( + 'MINIO.downloadExpiry', + )!; + + this.client = new Client({ + endPoint, + accessKey, + secretKey, + useSSL, + port, + }); + } + + public async setupEnvironment() { + this.logger.debug('Minio-Setup gestartet'); + + // Bucket erstellen + if (!(await this.client.bucketExists(this.bucketName))) { + await this.client.makeBucket(this.bucketName); + this.logger.debug('Minio Bucket erstellt'); + } + + this.logger.debug('Minio-Setup abgeschlossen'); + } + + public async generatePresignedPutUrl(file: string): Promise { + return this.client.presignedPutObject( + this.bucketName, + file, + this.uploadExpiry, + ); + } + + public async generatePresignedGetUrl(file: string): Promise { + return this.client.presignedGetObject( + this.bucketName, + file, + this.downloadExpiry, + ); + } + + public async generatePresignedPostUrl( + file: string, + contentType: string, + fileSizeMb: number, + ): Promise<{ + postURL: string; + formData: { + [key: string]: unknown; + }; + }> { + const policy = this.client.newPostPolicy(); + policy.setBucket(this.bucketName); + policy.setKey(file); + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + this.uploadExpiry); + policy.setExpires(expires); + policy.setContentType(contentType); + policy.setContentLengthRange(0, 1024 * 1024 * fileSizeMb); // in MB + + return this.client.presignedPostPolicy(policy); + } + + public checkImageTypes(contentType: string) { + return this.allowedImageTypes.some((x) => x === contentType); + } + + public getObject(key: string) { + return this.client.getObject(this.bucketName, key); + } + + public putObject( + key: string, + webpBuffer: Buffer, + metaData?: ItemBucketMetadata, + ) { + return this.client.putObject( + this.bucketName, + key, + webpBuffer, + webpBuffer.length, + metaData, + ); + } + + public async deleteObject(key: string) { + await this.client.removeObject(this.bucketName, key); + } + + async deleteImages(entity: string, id: number, imageId: string) { + await Promise.all([ + ...ImageService.sizes.map(async (size) => + this.deleteObject(`devices/${id}/images/${imageId}-webp-${size}`), + ), + ...ImageService.blurredSizes.map(async (size) => + this.deleteObject(`devices/${id}/images/${imageId}-webp-${size}-blur`), + ), + this.deleteObject(`devices/${id}/images/${imageId}`), + this.deleteObject(`devices/${id}/images/${imageId}-webp`), + ]); + } +} diff --git a/backend/src/core/user/user.controller.ts b/backend/src/core/user/user.controller.ts index 4989f37..e5fefed 100644 --- a/backend/src/core/user/user.controller.ts +++ b/backend/src/core/user/user.controller.ts @@ -76,7 +76,7 @@ export class UserController { roles: [Role.UserManage], noContent: true, }) - public deleteUser(@Param() params: IdGuidDto): Observable { + public deleteUser(@Param() params: IdGuidDto): Observable { return this.userService.deleteUser(params.id); } diff --git a/backend/src/core/user/user.service.ts b/backend/src/core/user/user.service.ts index efc1f89..e361d9e 100644 --- a/backend/src/core/user/user.service.ts +++ b/backend/src/core/user/user.service.ts @@ -83,7 +83,7 @@ export class UserService { ); } - deleteUser(id: string): Observable { + deleteUser(id: string): Observable { return this.keycloakService.deleteUser(id).pipe( catchError((error: InternalErrorDto) => { if (error.code === 404) { diff --git a/backend/src/inventory/consumable-group/consumable-group-db.service.ts b/backend/src/inventory/consumable-group/consumable-group-db.service.ts new file mode 100644 index 0000000..76467c2 --- /dev/null +++ b/backend/src/inventory/consumable-group/consumable-group-db.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { DeepPartial } from 'typeorm/common/DeepPartial'; +import { ConsumableGroupEntity } from './consumable-group.entity'; + +@Injectable() +export class ConsumableGroupDbService { + constructor( + @InjectRepository(ConsumableGroupEntity) + private readonly repo: Repository, + ) {} + + private searchQueryBuilder( + query: SelectQueryBuilder, + searchTerm: string, + ): SelectQueryBuilder { + return query.where('cg.name ilike :searchTerm', { + searchTerm: `%${searchTerm}%`, + }); + } + + public async getCount(searchTerm?: string) { + let query = this.repo.createQueryBuilder('cg'); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + return query.getCount(); + } + + public async findAll( + offset?: number, + limit?: number, + sortCol?: string, + sortDir?: 'ASC' | 'DESC', + searchTerm?: string, + ) { + let query = this.repo + .createQueryBuilder('cg') + .limit(limit ?? 100) + .offset(offset ?? 0); + + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + + if (sortCol) { + query = query.orderBy(`cg.${sortCol}`, sortDir ?? 'ASC'); + } else { + query = query.orderBy('cg.name'); + } + + return query.getMany(); + } + + public findOne(id: number) { + const query = this.repo + .createQueryBuilder('cg') + .where('cg.id = :id', { id }); + + return query.getOne(); + } + + public async create(entity: DeepPartial) { + return this.repo.save(entity); + } + + public async update(id: number, data: DeepPartial) { + const result = await this.repo.update(id, data); + return (result.affected ?? 0) > 0; + } + + public async delete(id: number) { + const result = await this.repo.delete(id); + return (result.affected ?? 0) > 0; + } +} diff --git a/backend/src/inventory/consumable-group/consumable-group.controller.ts b/backend/src/inventory/consumable-group/consumable-group.controller.ts new file mode 100644 index 0000000..7c1d5b6 --- /dev/null +++ b/backend/src/inventory/consumable-group/consumable-group.controller.ts @@ -0,0 +1,98 @@ +import { Body, Controller, Param, Query } from '@nestjs/common'; +import { + Endpoint, + EndpointType, +} from '../../shared/decorator/endpoint.decorator'; +import { CountDto } from '../../shared/dto/count.dto'; +import { Role } from '../../core/auth/role/role'; +import { IdNumberDto } from '../../shared/dto/id.dto'; +import { ConsumableGroupService } from './consumable-group.service'; +import { ConsumableGroupDto } from './dto/consumable-group.dto'; +import { ConsumableGroupUpdateDto } from './dto/consumable-group-update.dto'; +import { ConsumableGroupCreateDto } from './dto/consumable-group-create.dto'; +import { ConsumableGroupGetQueryDto } from './dto/consumable-group-get-query.dto'; +import { PaginationDto } from '../../shared/dto/pagination.dto'; +import { SearchDto } from '../../shared/dto/search.dto'; + +@Controller('consumable-group') +export class ConsumableGroupController { + constructor(private readonly service: ConsumableGroupService) {} + + @Endpoint(EndpointType.GET, { + path: 'count', + description: 'Gibt die Anzahl aller Verbrauchsgüter-Gruppen zurück', + responseType: CountDto, + roles: [Role.ConsumableGroupView], + }) + public getCount(@Query() search: SearchDto): Promise { + return this.service.getCount(search.searchTerm); + } + + @Endpoint(EndpointType.GET, { + path: '', + description: 'Gibt alle Verbrauchsgüter-Gruppen zurück', + responseType: [ConsumableGroupDto], + roles: [Role.ConsumableGroupView], + }) + public async getAll( + @Query() pagination: PaginationDto, + @Query() querys: ConsumableGroupGetQueryDto, + @Query() search: SearchDto, + ): Promise { + return this.service.findAll( + pagination.offset, + pagination.limit, + querys.sortCol, + querys.sortDir, + search.searchTerm, + ); + } + + @Endpoint(EndpointType.GET, { + path: ':id', + description: 'Gibt eine Verbrauchsgüter-Gruppe zurück', + responseType: ConsumableGroupDto, + notFound: true, + roles: [Role.ConsumableGroupView], + }) + public getOne(@Param() params: IdNumberDto): Promise { + return this.service.findOne(params.id); + } + + @Endpoint(EndpointType.POST, { + description: 'Erstellt eine Verbrauchsgüter-Gruppe', + responseType: ConsumableGroupDto, + notFound: true, + roles: [Role.ConsumableGroupManage], + }) + public create( + @Body() body: ConsumableGroupCreateDto, + ): Promise { + return this.service.create(body); + } + + @Endpoint(EndpointType.PUT, { + path: ':id', + description: 'Aktualisiert eine Verbrauchsgüter-Gruppe', + notFound: true, + responseType: ConsumableGroupDto, + roles: [Role.ConsumableGroupManage], + }) + public update( + @Param() params: IdNumberDto, + @Body() body: ConsumableGroupUpdateDto, + ): Promise { + return this.service.update(params.id, body); + } + + @Endpoint(EndpointType.DELETE, { + path: ':id', + description: 'Löscht eine Verbrauchsgüter-Gruppe', + noContent: true, + notFound: true, + roles: [Role.ConsumableGroupManage], + }) + public async delete(@Param() params: IdNumberDto): Promise { + await this.service.delete(params.id); + } +} diff --git a/backend/src/inventory/consumable-group/consumable-group.entity.ts b/backend/src/inventory/consumable-group/consumable-group.entity.ts new file mode 100644 index 0000000..c4cc88e --- /dev/null +++ b/backend/src/inventory/consumable-group/consumable-group.entity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { ConsumableEntity } from '../consumable/consumable.entity'; + +@Entity() +export class ConsumableGroupEntity { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column({ type: 'varchar', length: 100 }) + name: string; + @Column({ type: 'text', nullable: true }) + notice?: string; + + @OneToMany(() => ConsumableEntity, (x) => x.group) + consumables: ConsumableEntity[]; +} diff --git a/backend/src/inventory/consumable-group/consumable-group.service.spec.ts b/backend/src/inventory/consumable-group/consumable-group.service.spec.ts new file mode 100644 index 0000000..a6c3d9f --- /dev/null +++ b/backend/src/inventory/consumable-group/consumable-group.service.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConsumableGroupService } from './consumable-group.service'; +import { ConsumableGroupDbService } from './consumable-group-db.service'; +import { ConsumableGroupCreateDto } from './dto/consumable-group-create.dto'; + +describe('ConsumableGroupService', () => { + let service: ConsumableGroupService; + let dbService: ConsumableGroupDbService; + + const mockDbService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getCount: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ConsumableGroupService, + { + provide: ConsumableGroupDbService, + useValue: mockDbService, + }, + ], + }).compile(); + + service = module.get(ConsumableGroupService); + dbService = module.get(ConsumableGroupDbService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should create a consumable group', async () => { + const createDto: ConsumableGroupCreateDto = { + name: 'Test Consumable Group', + notice: 'Test notice', + }; + + const mockEntity = { + id: 1, + ...createDto, + }; + + mockDbService.create.mockResolvedValue(mockEntity); + mockDbService.findOne.mockResolvedValue(mockEntity); + + const result = await service.create(createDto); + + expect(result).toEqual(mockEntity); + expect(dbService.create).toHaveBeenCalledWith(createDto); + expect(dbService.findOne).toHaveBeenCalledWith(1); + }); + + it('should get count of consumable groups', async () => { + const mockCount = { count: 5 }; + mockDbService.getCount.mockResolvedValue(5); + + const result = await service.getCount(); + + expect(result).toEqual(mockCount); + expect(dbService.getCount).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/backend/src/inventory/consumable-group/consumable-group.service.ts b/backend/src/inventory/consumable-group/consumable-group.service.ts new file mode 100644 index 0000000..a074742 --- /dev/null +++ b/backend/src/inventory/consumable-group/consumable-group.service.ts @@ -0,0 +1,73 @@ +import { + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { CountDto } from '../../shared/dto/count.dto'; +import { ConsumableGroupDbService } from './consumable-group-db.service'; +import { ConsumableGroupDto } from './dto/consumable-group.dto'; +import { ConsumableGroupCreateDto } from './dto/consumable-group-create.dto'; +import { ConsumableGroupUpdateDto } from './dto/consumable-group-update.dto'; + +@Injectable() +export class ConsumableGroupService { + constructor(private readonly dbService: ConsumableGroupDbService) {} + + public async findAll( + offset?: number, + limit?: number, + sortCol?: string, + sortDir?: 'ASC' | 'DESC', + searchTerm?: string, + ) { + const entities = await this.dbService.findAll( + offset, + limit, + sortCol, + sortDir, + searchTerm, + ); + return plainToInstance(ConsumableGroupDto, entities); + } + + public async findOne(id: number) { + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new NotFoundException(); + } + return plainToInstance(ConsumableGroupDto, entity); + } + + public async delete(id: number) { + if (!(await this.dbService.delete(id))) { + throw new NotFoundException(); + } + } + + public async getCount(searchTerm?: string) { + const count = await this.dbService.getCount(searchTerm); + return plainToInstance(CountDto, { count }); + } + + public async create(body: ConsumableGroupCreateDto) { + const newEntity = await this.dbService.create(body); + const entity = await this.dbService.findOne(newEntity.id); + if (!entity) { + throw new InternalServerErrorException(); + } + return plainToInstance(ConsumableGroupDto, entity); + } + + public async update(id: number, body: ConsumableGroupUpdateDto) { + if (!(await this.dbService.update(id, body))) { + throw new NotFoundException(); + } + + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new NotFoundException(); + } + return plainToInstance(ConsumableGroupDto, entity); + } +} diff --git a/backend/src/inventory/consumable-group/dto/consumable-group-create.dto.ts b/backend/src/inventory/consumable-group/dto/consumable-group-create.dto.ts new file mode 100644 index 0000000..4927d69 --- /dev/null +++ b/backend/src/inventory/consumable-group/dto/consumable-group-create.dto.ts @@ -0,0 +1,6 @@ +import { OmitType } from '@nestjs/swagger'; +import { ConsumableGroupDto } from './consumable-group.dto'; + +export class ConsumableGroupCreateDto extends OmitType(ConsumableGroupDto, [ + 'id', +] as const) {} diff --git a/backend/src/inventory/consumable-group/dto/consumable-group-get-query.dto.ts b/backend/src/inventory/consumable-group/dto/consumable-group-get-query.dto.ts new file mode 100644 index 0000000..df18207 --- /dev/null +++ b/backend/src/inventory/consumable-group/dto/consumable-group-get-query.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumberString, IsOptional, IsString } from 'class-validator'; + +export class ConsumableGroupGetQueryDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsNumberString() + offset?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumberString() + limit?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + sortCol?: string; + + @ApiProperty({ required: false, enum: ['ASC', 'DESC'] }) + @IsOptional() + @IsString() + sortDir?: 'ASC' | 'DESC'; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + searchTerm?: string; +} diff --git a/backend/src/inventory/consumable-group/dto/consumable-group-update.dto.ts b/backend/src/inventory/consumable-group/dto/consumable-group-update.dto.ts new file mode 100644 index 0000000..d1bd867 --- /dev/null +++ b/backend/src/inventory/consumable-group/dto/consumable-group-update.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { ConsumableGroupCreateDto } from './consumable-group-create.dto'; + +export class ConsumableGroupUpdateDto extends PartialType( + ConsumableGroupCreateDto, +) {} diff --git a/backend/src/inventory/consumable-group/dto/consumable-group.dto.ts b/backend/src/inventory/consumable-group/dto/consumable-group.dto.ts new file mode 100644 index 0000000..70f3341 --- /dev/null +++ b/backend/src/inventory/consumable-group/dto/consumable-group.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + IsDefined, + IsInt, + IsOptional, + IsPositive, + Length, + MaxLength, +} from 'class-validator'; + +export class ConsumableGroupDto { + @ApiProperty() + @Expose() + @IsPositive() + @IsDefined() + @IsInt() + id: number; + + @ApiProperty() + @Expose() + @IsDefined() + @Length(1, 100) + name: string; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @IsOptional() + @MaxLength(2000) + notice?: string; +} diff --git a/backend/src/inventory/consumable/consumable-db.service.ts b/backend/src/inventory/consumable/consumable-db.service.ts new file mode 100644 index 0000000..2c249eb --- /dev/null +++ b/backend/src/inventory/consumable/consumable-db.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { DeepPartial } from 'typeorm/common/DeepPartial'; +import { ConsumableEntity } from './consumable.entity'; +import { ConsumableLocationEntity } from './consumable-location.entity'; + +@Injectable() +export class ConsumableDbService { + constructor( + @InjectRepository(ConsumableEntity) + private readonly repo: Repository, + @InjectRepository(ConsumableLocationEntity) + private readonly clRepo: Repository, + ) {} + + private searchQueryBuilder( + query: SelectQueryBuilder, + searchTerm: string, + ): SelectQueryBuilder { + return query.where('c.name ilike :searchTerm', { + searchTerm: `%${searchTerm}%`, + }); + } + + public async getCount(searchTerm?: string) { + let query = this.repo.createQueryBuilder('c'); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + return query.getCount(); + } + + public async findAll( + offset?: number, + limit?: number, + groupId?: number, + locationIds?: number[], + sortCol?: string, + sortDir?: 'ASC' | 'DESC', + searchTerm?: string, + ) { + let query = this.repo + .createQueryBuilder('c') + .limit(limit ?? 100) + .offset(offset ?? 0) + .leftJoinAndSelect('c.group', 'cg') + .leftJoinAndSelect('c.consumableLocations', 'cl') + .leftJoinAndSelect('cl.location', 'l') + .leftJoinAndSelect('l.parent', 'lp'); + + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + + if (groupId) { + query = query.andWhere('c.groupId = :groupId', { groupId }); + } + + if (locationIds && locationIds.length > 0) { + query = query.andWhere('l.id IN (:...locationIds)', { locationIds }); + } + + if (sortCol) { + query = query.orderBy(`c.${sortCol}`, sortDir ?? 'ASC'); + } else { + query = query.orderBy('c.name'); + } + + return query.getMany(); + } + + public findOne(id: number) { + const query = this.repo + .createQueryBuilder('c') + .where('c.id = :id', { id }) + .leftJoinAndSelect('c.group', 'cg') + .leftJoinAndSelect('c.consumableLocations', 'cl') + .leftJoinAndSelect('cl.location', 'l') + .leftJoinAndSelect('l.parent', 'lp'); + + return query.getOne(); + } + + public async create(entity: DeepPartial) { + return this.repo.save(entity); + } + + public async update(id: number, data: DeepPartial) { + const result = await this.repo.update(id, data); + return (result.affected ?? 0) > 0; + } + + public async delete(id: number) { + const result = await this.repo.delete(id); + return (result.affected ?? 0) > 0; + } + + public async addLocation( + id: number, + body: DeepPartial, + ) { + await this.clRepo.save({ ...body, consumableId: id }); + } + + public async removeLocation(consumableId: number, relationId: number) { + const result = await this.clRepo + .createQueryBuilder() + .where('id = :id', { id: relationId }) + .andWhere('consumableId = :consumableId', { consumableId }) + .delete() + .execute(); + return result.affected && result.affected !== 0; + } + + public async findLocationRelation(relationId: number, consumableId: number) { + return await this.clRepo.findOneBy({ id: relationId, consumableId }); + } + + public async updateLocation( + consumableId: number, + relationId: number, + data: DeepPartial, + ) { + const result = await this.clRepo.update( + { id: relationId, consumableId }, + data, + ); + return (result.affected ?? 0) > 0; + } +} diff --git a/backend/src/inventory/consumable/consumable-location.entity.ts b/backend/src/inventory/consumable/consumable-location.entity.ts new file mode 100644 index 0000000..42d7395 --- /dev/null +++ b/backend/src/inventory/consumable/consumable-location.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { LocationEntity } from '../../base/location/location.entity'; +import { ConsumableEntity } from './consumable.entity'; + +@Entity() +export class ConsumableLocationEntity { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column({ type: 'int' }) + quantity: number; + @Column({ type: 'date', nullable: true }) + expirationDate?: Date; + @Column({ type: 'text', nullable: true }) + notice?: string; + + @Column() + locationId: number; + @Column() + consumableId: number; + + @ManyToOne(() => LocationEntity, (x) => x.consumableLocations, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + @JoinColumn() + location: LocationEntity; + + @ManyToOne(() => ConsumableEntity, (x) => x.consumableLocations, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + @JoinColumn() + consumable: ConsumableEntity; +} diff --git a/backend/src/inventory/consumable/consumable.controller.ts b/backend/src/inventory/consumable/consumable.controller.ts new file mode 100644 index 0000000..d3796e8 --- /dev/null +++ b/backend/src/inventory/consumable/consumable.controller.ts @@ -0,0 +1,144 @@ +import { Body, Controller, Param, Query } from '@nestjs/common'; +import { + Endpoint, + EndpointType, +} from '../../shared/decorator/endpoint.decorator'; +import { CountDto } from '../../shared/dto/count.dto'; +import { Role } from '../../core/auth/role/role'; +import { IdNumberDto } from '../../shared/dto/id.dto'; +import { ConsumableService } from './consumable.service'; +import { ConsumableDto } from './dto/consumable.dto'; +import { ConsumableGetQueryDto } from './dto/consumable-get-query.dto'; +import { ConsumableUpdateDto } from './dto/consumable-update.dto'; +import { ConsumableCreateDto } from './dto/consumable-create.dto'; +import { PaginationDto } from '../../shared/dto/pagination.dto'; +import { SearchDto } from '../../shared/dto/search.dto'; +import { ConsumableLocationAddDto } from './dto/consumable-location-add.dto'; +import { ConsumableLocationUpdateDto } from './dto/consumable-location-update.dto'; + +@Controller('consumable') +export class ConsumableController { + constructor(private readonly service: ConsumableService) {} + + @Endpoint(EndpointType.GET, { + path: 'count', + description: 'Gibt die Anzahl aller Verbrauchsgüter zurück', + responseType: CountDto, + roles: [Role.ConsumableView], + }) + public getCount(@Query() search: SearchDto): Promise { + return this.service.getCount(search.searchTerm); + } + + @Endpoint(EndpointType.GET, { + path: '', + description: 'Gibt alle Verbrauchsgüter zurück', + responseType: [ConsumableDto], + roles: [Role.ConsumableView], + }) + public async getAll( + @Query() pagination: PaginationDto, + @Query() querys: ConsumableGetQueryDto, + @Query() search: SearchDto, + ): Promise { + return this.service.findAll( + pagination.offset, + pagination.limit, + querys.groupId, + querys.locationIds, + querys.sortCol, + querys.sortDir, + search.searchTerm, + ); + } + + @Endpoint(EndpointType.GET, { + path: ':id', + description: 'Gibt ein Verbrauchsgut zurück', + responseType: ConsumableDto, + notFound: true, + roles: [Role.ConsumableView], + }) + public getOne(@Param() params: IdNumberDto): Promise { + return this.service.findOne(params.id); + } + + @Endpoint(EndpointType.POST, { + description: 'Erstellt ein Verbrauchsgut', + responseType: ConsumableDto, + notFound: true, + roles: [Role.ConsumableManage], + }) + public create(@Body() body: ConsumableCreateDto): Promise { + return this.service.create(body); + } + + @Endpoint(EndpointType.PUT, { + path: ':id', + description: 'Aktualisiert ein Verbrauchsgut', + notFound: true, + responseType: ConsumableDto, + roles: [Role.ConsumableManage], + }) + public update( + @Param() params: IdNumberDto, + @Body() body: ConsumableUpdateDto, + ): Promise { + return this.service.update(params.id, body); + } + + @Endpoint(EndpointType.DELETE, { + path: ':id', + description: 'Löscht ein Verbrauchsgut', + noContent: true, + notFound: true, + roles: [Role.ConsumableManage], + }) + public async delete(@Param() params: IdNumberDto): Promise { + await this.service.delete(params.id); + } + + @Endpoint(EndpointType.POST, { + path: ':id/locations', + description: 'Fügt einem Verbrauchsgut einen Standort hinzu', + responseType: ConsumableDto, + notFound: true, + roles: [Role.ConsumableManage], + }) + public addLocation( + @Param('id') id: number, + @Body() body: ConsumableLocationAddDto, + ): Promise { + return this.service.addLocation(id, body); + } + + @Endpoint(EndpointType.DELETE, { + path: ':id/locations/:relationId', + description: 'Entfernt einen Standort von einem Verbrauchsgut', + responseType: ConsumableDto, + notFound: true, + roles: [Role.ConsumableManage], + }) + public removeLocation( + @Param('id') id: number, + @Param('relationId') relationId: number, + ): Promise { + return this.service.removeLocation(id, relationId); + } + + @Endpoint(EndpointType.PUT, { + path: ':id/locations/:relationId', + description: + 'Aktualisiert die Relation zwischen einem Verbrauchsgut und einen Standort.', + responseType: ConsumableDto, + notFound: true, + roles: [Role.ConsumableManage], + }) + public updateLocation( + @Param('id') id: number, + @Param('relationId') relationId: number, + @Body() body: ConsumableLocationUpdateDto, + ): Promise { + return this.service.updateLocation(id, relationId, body); + } +} diff --git a/backend/src/inventory/consumable/consumable.entity.ts b/backend/src/inventory/consumable/consumable.entity.ts new file mode 100644 index 0000000..90a1f61 --- /dev/null +++ b/backend/src/inventory/consumable/consumable.entity.ts @@ -0,0 +1,32 @@ +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { ConsumableGroupEntity } from '../consumable-group/consumable-group.entity'; +import { ConsumableLocationEntity } from './consumable-location.entity'; + +@Entity() +export class ConsumableEntity { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column({ type: 'varchar', length: 100, nullable: true }) + name?: string; + @Column({ type: 'text', nullable: true }) + notice?: string; + + @Column({ nullable: true }) + groupId?: number; + @ManyToOne(() => ConsumableGroupEntity, (x) => x.consumables, { + nullable: true, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }) + group?: ConsumableGroupEntity; + + @OneToMany(() => ConsumableLocationEntity, (x) => x.consumable) + consumableLocations?: ConsumableLocationEntity[]; +} diff --git a/backend/src/inventory/consumable/consumable.service.spec.ts b/backend/src/inventory/consumable/consumable.service.spec.ts new file mode 100644 index 0000000..3f75875 --- /dev/null +++ b/backend/src/inventory/consumable/consumable.service.spec.ts @@ -0,0 +1,50 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConsumableService } from './consumable.service'; +import { ConsumableDbService } from './consumable-db.service'; +import { ConsumableEntity } from './consumable.entity'; +import { LocationEntity } from '../../base/location/location.entity'; +import { ConsumableCreateDto } from './dto/consumable-create.dto'; +import { LocationDbService } from '../../base/location/location-db.service'; + +describe('ConsumableService', () => { + let service: ConsumableService; + let dbService: ConsumableDbService; + let locationDbService: LocationDbService; + + const mockDbService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getCount: jest.fn(), + }; + + const locationDbServiceMock = {}; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ConsumableService, + { + provide: ConsumableDbService, + useValue: mockDbService, + }, + { + provide: LocationDbService, + useValue: locationDbServiceMock, + }, + ], + }).compile(); + + service = module.get(ConsumableService); + dbService = module.get(ConsumableDbService); + locationDbService = module.get(LocationDbService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/inventory/consumable/consumable.service.ts b/backend/src/inventory/consumable/consumable.service.ts new file mode 100644 index 0000000..41fa510 --- /dev/null +++ b/backend/src/inventory/consumable/consumable.service.ts @@ -0,0 +1,154 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { CountDto } from '../../shared/dto/count.dto'; +import { ConsumableDbService } from './consumable-db.service'; +import { ConsumableDto } from './dto/consumable.dto'; +import { ConsumableCreateDto } from './dto/consumable-create.dto'; +import { ConsumableUpdateDto } from './dto/consumable-update.dto'; +import { ConsumableLocationAddDto } from './dto/consumable-location-add.dto'; +import { LocationDbService } from '../../base/location/location-db.service'; +import { ConsumableLocationUpdateDto } from './dto/consumable-location-update.dto'; + +@Injectable() +export class ConsumableService { + constructor( + private readonly dbService: ConsumableDbService, + private readonly locationDbService: LocationDbService, + ) {} + + public async findAll( + offset?: number, + limit?: number, + groupId?: number, + locationIds?: number[], + sortCol?: string, + sortDir?: 'ASC' | 'DESC', + searchTerm?: string, + ) { + const entities = await this.dbService.findAll( + offset, + limit, + groupId, + locationIds, + sortCol, + sortDir, + searchTerm, + ); + return plainToInstance(ConsumableDto, entities); + } + + public async findOne(id: number) { + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new NotFoundException(); + } + return plainToInstance(ConsumableDto, entity); + } + + public async delete(id: number) { + if (!(await this.dbService.delete(id))) { + throw new NotFoundException(); + } + } + + public async getCount(searchTerm?: string) { + const count = await this.dbService.getCount(searchTerm); + return plainToInstance(CountDto, { count }); + } + + public async create(body: ConsumableCreateDto) { + const newEntity = await this.dbService.create(body); + + const entity = await this.dbService.findOne(newEntity.id); + if (!entity) { + throw new InternalServerErrorException(); + } + return plainToInstance(ConsumableDto, entity); + } + + public async update(id: number, body: ConsumableUpdateDto) { + if (!(await this.dbService.update(id, body))) { + throw new NotFoundException(); + } + + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new NotFoundException(); + } + return plainToInstance(ConsumableDto, entity); + } + + public async addLocation(id: number, body: ConsumableLocationAddDto) { + // Check if consumable exists + const consumable = await this.dbService.findOne(id); + if (!consumable) { + throw new NotFoundException('Consumable not found'); + } + + // Check if location exists + const location = await this.locationDbService.findOne(body.locationId); + if (!location) { + throw new NotFoundException('Location not found'); + } + + // Add the location to the consumable + await this.dbService.addLocation(id, body); + + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new InternalServerErrorException(); + } + return plainToInstance(ConsumableDto, entity); + } + + public async removeLocation(id: number, relationId: number) { + if (!(await this.dbService.removeLocation(id, relationId))) { + throw new NotFoundException(); + } + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new InternalServerErrorException(); + } + return plainToInstance(ConsumableDto, entity); + } + + public async updateLocation( + id: number, + relationId: number, + data: ConsumableLocationUpdateDto, + ) { + // Check if relation exists + const relation = await this.dbService.findLocationRelation(relationId, id); + if (!relation) { + throw new NotFoundException('Relation not found'); + } + + // Check if consumable exists + const consumable = await this.dbService.findOne(id); + if (!consumable) { + throw new NotFoundException('Consumable not found'); + } + + // Check if location exists + const location = await this.locationDbService.findOne(data.locationId); + if (!location) { + throw new NotFoundException('Location not found'); + } + + // Update the location relation + if (!(await this.dbService.updateLocation(id, relationId, data))) { + throw new BadRequestException('Failed to update location relation'); + } + + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new InternalServerErrorException(); + } + return plainToInstance(ConsumableDto, entity); + } +} diff --git a/backend/src/inventory/consumable/dto/consumable-create.dto.ts b/backend/src/inventory/consumable/dto/consumable-create.dto.ts new file mode 100644 index 0000000..ac08bcb --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-create.dto.ts @@ -0,0 +1,9 @@ +import { OmitType } from '@nestjs/swagger'; +import { ConsumableDto } from './consumable.dto'; + +export class ConsumableCreateDto extends OmitType(ConsumableDto, [ + 'id', + 'group', + 'consumableLocations', + 'consumableLocationIds', +] as const) {} diff --git a/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts b/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts new file mode 100644 index 0000000..d008087 --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class ConsumableGetQueryDto { + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsInt() + @IsPositive() + groupId?: number; + + @ApiProperty({ required: false, nullable: true, type: [Number] }) + @IsOptional() + @Transform(({ value }: TransformFnParams) => { + if (typeof value === 'string') { + return value.split(',').map((id) => parseInt(id, 10)); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; + }) + @IsInt({ each: true }) + @IsPositive({ each: true }) + locationIds?: number[]; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @IsIn(['id', 'name', 'quantity', 'expirationDate', 'group.name']) + sortCol?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @IsIn(['ASC', 'DESC']) + sortDir?: 'ASC' | 'DESC'; +} diff --git a/backend/src/inventory/consumable/dto/consumable-location-add.dto.ts b/backend/src/inventory/consumable/dto/consumable-location-add.dto.ts new file mode 100644 index 0000000..2a0cfe5 --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-location-add.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { ConsumableLocationDto } from './consumable-location.dto'; + +export class ConsumableLocationAddDto extends OmitType(ConsumableLocationDto, [ + 'id', + 'location', +] as const) {} diff --git a/backend/src/inventory/consumable/dto/consumable-location-update.dto.ts b/backend/src/inventory/consumable/dto/consumable-location-update.dto.ts new file mode 100644 index 0000000..41d075e --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-location-update.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { ConsumableLocationDto } from './consumable-location.dto'; + +export class ConsumableLocationUpdateDto extends OmitType( + ConsumableLocationDto, + ['id', 'location'] as const, +) {} diff --git a/backend/src/inventory/consumable/dto/consumable-location.dto.ts b/backend/src/inventory/consumable/dto/consumable-location.dto.ts new file mode 100644 index 0000000..4f86125 --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-location.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsDate, + IsDefined, + IsInt, + IsOptional, + IsPositive, + IsString, + MaxLength, +} from 'class-validator'; +import { LocationDto } from '../../../base/location/dto/location.dto'; + +export class ConsumableLocationDto { + @ApiProperty() + @Expose() + @IsDefined() + @IsInt() + @IsPositive() + id: number; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @IsOptional() + @IsString() + @MaxLength(2000) + notice?: string; + + @ApiProperty() + @Expose() + @IsDefined() + @IsInt() + @IsPositive() + quantity: number; + + @ApiProperty({ required: false, nullable: true, type: Date }) + @Expose() + @IsOptional() + @Type(() => Date) + @IsDate() + expirationDate?: Date; + + @ApiProperty({ required: true, nullable: false }) + @Expose() + @IsDefined() + @IsInt() + @IsPositive() + locationId: number; + + @ApiProperty({ type: LocationDto }) + @Expose() + @Type(() => LocationDto) + location?: LocationDto; +} diff --git a/backend/src/inventory/consumable/dto/consumable-update.dto.ts b/backend/src/inventory/consumable/dto/consumable-update.dto.ts new file mode 100644 index 0000000..e4b66b9 --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-update.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { ConsumableCreateDto } from './consumable-create.dto'; + +export class ConsumableUpdateDto extends PartialType(ConsumableCreateDto) {} diff --git a/backend/src/inventory/consumable/dto/consumable.dto.ts b/backend/src/inventory/consumable/dto/consumable.dto.ts new file mode 100644 index 0000000..7350c05 --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsDefined, + IsInt, + IsOptional, + IsPositive, + IsString, + MaxLength, +} from 'class-validator'; +import { ConsumableGroupDto } from '../../consumable-group/dto/consumable-group.dto'; +import { ConsumableLocationDto } from './consumable-location.dto'; + +export class ConsumableDto { + @ApiProperty() + @Expose() + @IsDefined() + @IsInt() + @IsPositive() + id: number; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @IsOptional() + @IsString() + @MaxLength(2000) + notice?: string; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @IsOptional() + @IsInt() + @IsPositive() + groupId?: number; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @Type(() => ConsumableGroupDto) + group?: ConsumableGroupDto; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @IsOptional() + @IsInt({ each: true }) + @IsPositive({ each: true }) + consumableLocationIds?: number[]; + + @ApiProperty({ + required: false, + nullable: true, + type: [ConsumableLocationDto], + }) + @Expose() + @Type(() => ConsumableLocationDto) + consumableLocations?: ConsumableLocationDto[]; +} diff --git a/backend/src/inventory/device/device-db.service.ts b/backend/src/inventory/device/device-db.service.ts index c82a4c6..577f609 100644 --- a/backend/src/inventory/device/device-db.service.ts +++ b/backend/src/inventory/device/device-db.service.ts @@ -3,12 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, SelectQueryBuilder } from 'typeorm'; import { DeepPartial } from 'typeorm/common/DeepPartial'; import { DeviceEntity } from './device.entity'; +import { DeviceImageEntity } from './device-image.entity'; @Injectable() export class DeviceDbService { constructor( @InjectRepository(DeviceEntity) private readonly repo: Repository, + @InjectRepository(DeviceImageEntity) + private readonly imageRepo: Repository, ) {} private searchQueryBuilder( @@ -45,7 +48,12 @@ export class DeviceDbService { .leftJoinAndSelect('d.type', 'dt') .leftJoinAndSelect('d.group', 'dg') .leftJoinAndSelect('d.location', 'l') - .leftJoinAndSelect('l.parent', 'lp'); + .leftJoinAndSelect('l.parent', 'lp') + .leftJoinAndSelect('d.defaultImage', 'di'); + + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } if (searchTerm) { query = this.searchQueryBuilder(query, searchTerm); @@ -94,6 +102,8 @@ export class DeviceDbService { .leftJoinAndSelect('d.group', 'dg') .leftJoinAndSelect('d.location', 'l') .leftJoinAndSelect('l.parent', 'lp') + .leftJoinAndSelect('d.images', 'i') + .leftJoinAndSelect('d.defaultImage', 'di') .where('d.id = :id', { id }); return query.getOne(); @@ -112,4 +122,17 @@ export class DeviceDbService { const result = await this.repo.delete(id); return (result.affected ?? 0) > 0; } + + public async addImage(device: DeviceEntity, imageId: string) { + await this.imageRepo.save({ device, id: imageId }); + } + + public async findImage(deviceId: number, imageId: string) { + return this.imageRepo.findOneBy({ deviceId, id: imageId }); + } + + async deleteImage(id: number, imageId: string) { + const result = await this.imageRepo.delete({ deviceId: id, id: imageId }); + return (result.affected ?? 0) > 0; + } } diff --git a/backend/src/inventory/device/device-image.entity.ts b/backend/src/inventory/device/device-image.entity.ts new file mode 100644 index 0000000..3d459cc --- /dev/null +++ b/backend/src/inventory/device/device-image.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { DeviceEntity } from './device.entity'; + +@Entity() +export class DeviceImageEntity { + @PrimaryColumn('uuid') + id: string; + + @Column() + deviceId: number; + @ManyToOne(() => DeviceEntity, (x) => x.images) + device: DeviceEntity; +} diff --git a/backend/src/inventory/device/device.controller.ts b/backend/src/inventory/device/device.controller.ts index 2b1e6d3..448abda 100644 --- a/backend/src/inventory/device/device.controller.ts +++ b/backend/src/inventory/device/device.controller.ts @@ -13,6 +13,11 @@ import { DeviceUpdateDto } from './dto/device-update.dto'; import { DeviceCreateDto } from './dto/device-create.dto'; import { PaginationDto } from '../../shared/dto/pagination.dto'; import { SearchDto } from '../../shared/dto/search.dto'; +import { UploadUrlDto } from '../../shared/dto/upload-url.dto'; +import { FilenameDto } from '../../shared/dto/filename.dto'; +import { DownloadUrlDto } from '../../shared/dto/download-url.dto'; +import { ImageIdGuidDto } from '../../shared/dto/image-id-guid.dto'; +import { ImageDownloadQuerysDto } from '../../shared/dto/image-download-querys.dto'; @Controller('device') export class DeviceController { @@ -96,4 +101,48 @@ export class DeviceController { public async delete(@Param() params: IdNumberDto): Promise { await this.service.delete(params.id); } + + @Endpoint(EndpointType.GET, { + path: ':id/image-upload', + description: 'Gibt die URL zum Hochladen eines Gerätebildes zurück', + notFound: true, + responseType: UploadUrlDto, + roles: [Role.DeviceManage], + }) + public async getImageUploadUrl( + @Param() params: IdNumberDto, + @Query() querys: FilenameDto, + ): Promise { + return this.service.getImageUploadUrl(params.id, querys.contentType); + } + + @Endpoint(EndpointType.GET, { + path: ':id/image/:imageId', + description: 'Gibt die Url zum herunterladen des Gerätebild zurück.', + notFound: true, + responseType: DownloadUrlDto, + roles: [Role.DeviceView], + }) + public async downloadImage( + @Param() params: IdNumberDto, + @Param() imageId: ImageIdGuidDto, + @Query() querys: ImageDownloadQuerysDto, + ): Promise { + return this.service.downloadImage(params.id, imageId.imageId, querys.size); + } + + // TODO Delete image endpoint + @Endpoint(EndpointType.DELETE, { + path: ':id/image/:imageId', + description: 'Löscht ein Bild', + noContent: true, + notFound: true, + roles: [Role.DeviceManage], + }) + public async deleteImage( + @Param() params: IdNumberDto, + @Param() imageId: ImageIdGuidDto, + ): Promise { + await this.service.deleteImage(params.id, imageId.imageId); + } } diff --git a/backend/src/inventory/device/device.entity.ts b/backend/src/inventory/device/device.entity.ts index 221d289..29ee088 100644 --- a/backend/src/inventory/device/device.entity.ts +++ b/backend/src/inventory/device/device.entity.ts @@ -1,7 +1,16 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import { DeviceTypeEntity } from '../device-type/device-type.entity'; import { DeviceGroupEntity } from '../device-group/device-group.entity'; import { LocationEntity } from '../../base/location/location.entity'; +import { DeviceImageEntity } from './device-image.entity'; export enum EquipmentState { ACTIVE = 0, @@ -76,4 +85,17 @@ export class DeviceEntity { onUpdate: 'CASCADE', }) location?: LocationEntity; + + @OneToMany(() => DeviceImageEntity, (x) => x.device, { onDelete: 'CASCADE' }) + images: DeviceImageEntity[]; + + @Column({ nullable: true }) + defaultImageId?: string; + @JoinColumn() + @OneToOne(() => DeviceImageEntity, { + nullable: true, + cascade: true, + onDelete: 'SET NULL', + }) + defaultImage?: DeviceImageEntity; } diff --git a/backend/src/inventory/device/device.service.ts b/backend/src/inventory/device/device.service.ts index 80e328e..8341090 100644 --- a/backend/src/inventory/device/device.service.ts +++ b/backend/src/inventory/device/device.service.ts @@ -1,4 +1,7 @@ import { + BadRequestException, + forwardRef, + Inject, Injectable, InternalServerErrorException, NotFoundException, @@ -9,10 +12,19 @@ import { DeviceDbService } from './device-db.service'; import { DeviceDto } from './dto/device.dto'; import { DeviceUpdateDto } from './dto/device-update.dto'; import { DeviceCreateDto } from './dto/device-create.dto'; +import { v4 } from 'uuid'; +import { MinioService } from '../../core/services/storage/minio.service'; +import { UploadUrlDto } from '../../shared/dto/upload-url.dto'; +import { ImageService } from '../../core/services/storage/image.service'; +import { DownloadUrlDto } from '../../shared/dto/download-url.dto'; @Injectable() export class DeviceService { - constructor(private readonly dbService: DeviceDbService) {} + constructor( + private readonly dbService: DeviceDbService, + @Inject(forwardRef(() => MinioService)) + private readonly minioService: MinioService, + ) {} public async findAll( offset?: number, @@ -42,10 +54,34 @@ export class DeviceService { if (!entity) { throw new NotFoundException(); } - return plainToInstance(DeviceDto, entity); + + const imgs: { id: string; previewUrl: string }[] = []; + const images = entity.images; + for (const img of images) { + const presignedUrl = await this.minioService.generatePresignedGetUrl( + 'devices/' + id + '/images/' + img.id + ImageService.previewSuffix, + ); + imgs.push({ + id: img.id, + previewUrl: presignedUrl, + }); + } + + return plainToInstance(DeviceDto, { ...entity, images: imgs }); } public async delete(id: number) { + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new NotFoundException(); + } + + if (entity.images && entity.images.length > 0) { + await Promise.all( + entity.images.map((img) => this.deleteImage(id, img.id)), + ); + } + if (!(await this.dbService.delete(id))) { throw new NotFoundException(); } @@ -70,10 +106,64 @@ export class DeviceService { throw new NotFoundException(); } - const entity = await this.dbService.findOne(id); - if (!entity) { + return this.findOne(id); + } + + public async getImageUploadUrl(id: number, contentType: string) { + const device = await this.dbService.findOne(id); + if (!device) { throw new NotFoundException(); } - return plainToInstance(DeviceDto, entity); + + if (!this.minioService.checkImageTypes(contentType)) { + throw new BadRequestException('Invalid file extension'); + } + + const uuid = v4(); + const minioPath = `devices/${device.id}/images/${uuid}`; + + const url = await this.minioService.generatePresignedPostUrl( + minioPath, + contentType, + 50, // 50 MB + ); + return plainToInstance( + UploadUrlDto, + { ...url, id: uuid }, + { excludeExtraneousValues: true }, + ); + } + + async addImage(deviceId: number, imageId: string) { + if (!Number.isInteger(deviceId)) { + throw new Error('deviceId must be an integer'); + } + + const device = await this.dbService.findOne(deviceId); + + if (!device) { + throw new NotFoundException(); + } + + await this.dbService.addImage(device, imageId); + } + + async downloadImage(deviceId: number, imageId: string, size: string) { + const image = await this.dbService.findImage(deviceId, imageId); + if (!image) { + throw new NotFoundException('Image not found'); + } + const url = await this.minioService.generatePresignedGetUrl( + `devices/${deviceId}/images/${imageId}` + (size !== '' ? '-' + size : ''), + ); + return plainToInstance(DownloadUrlDto, { url }); + } + + public async deleteImage(id: number, imageId: string) { + if (!(await this.dbService.deleteImage(id, imageId))) { + throw new NotFoundException('Image not found'); + } + + await this.minioService.deleteImages('devices', id, imageId); } } diff --git a/backend/src/inventory/device/dto/device-create.dto.ts b/backend/src/inventory/device/dto/device-create.dto.ts index 74f1624..1f08804 100644 --- a/backend/src/inventory/device/dto/device-create.dto.ts +++ b/backend/src/inventory/device/dto/device-create.dto.ts @@ -4,4 +4,9 @@ import { DeviceDto } from './device.dto'; export class DeviceCreateDto extends OmitType(DeviceDto, [ 'id', 'type', + 'defaultImage', + 'defaultImageId', + 'images', + 'location', + 'group', ] as const) {} diff --git a/backend/src/inventory/device/dto/device-image.dto.ts b/backend/src/inventory/device/dto/device-image.dto.ts new file mode 100644 index 0000000..c947c0f --- /dev/null +++ b/backend/src/inventory/device/dto/device-image.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class DeviceImageDto { + @ApiProperty() + @Expose() + id: string; + + @ApiProperty() + @Expose() + previewUrl: string; +} diff --git a/backend/src/inventory/device/dto/device-update.dto.ts b/backend/src/inventory/device/dto/device-update.dto.ts index 477d2d5..0214442 100644 --- a/backend/src/inventory/device/dto/device-update.dto.ts +++ b/backend/src/inventory/device/dto/device-update.dto.ts @@ -4,4 +4,8 @@ import { DeviceDto } from './device.dto'; export class DeviceUpdateDto extends OmitType(DeviceDto, [ 'id', 'type', + 'defaultImage', + 'images', + 'location', + 'group', ] as const) {} diff --git a/backend/src/inventory/device/dto/device.dto.ts b/backend/src/inventory/device/dto/device.dto.ts index 0e4cdb4..255a259 100644 --- a/backend/src/inventory/device/dto/device.dto.ts +++ b/backend/src/inventory/device/dto/device.dto.ts @@ -10,9 +10,11 @@ import { IsOptional, IsPositive, IsString, + IsUUID, MaxLength, } from 'class-validator'; import { LocationDto } from '../../../base/location/dto/location.dto'; +import { DeviceImageDto } from './device-image.dto'; export class DeviceDto { @ApiProperty() @@ -147,4 +149,21 @@ export class DeviceDto { @Expose() @Type(() => LocationDto) location?: LocationDto; + + @ApiProperty({ required: false, nullable: true, type: [DeviceImageDto] }) + @Expose() + @Type(() => DeviceImageDto) + images?: DeviceImageDto[]; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @IsOptional() + @IsString() + @IsUUID() + defaultImageId?: string; + + @ApiProperty({ required: false, nullable: true, type: DeviceImageDto }) + @Expose() + @Type(() => DeviceImageDto) + defaultImage?: DeviceImageDto; } diff --git a/backend/src/inventory/inventory.module.ts b/backend/src/inventory/inventory.module.ts index d2f9604..522d14f 100644 --- a/backend/src/inventory/inventory.module.ts +++ b/backend/src/inventory/inventory.module.ts @@ -12,6 +12,18 @@ import { DeviceController } from './device/device.controller'; import { DeviceService } from './device/device.service'; import { DeviceDbService } from './device/device-db.service'; import { DeviceEntity } from './device/device.entity'; +import { ConsumableGroupController } from './consumable-group/consumable-group.controller'; +import { ConsumableGroupService } from './consumable-group/consumable-group.service'; +import { ConsumableGroupDbService } from './consumable-group/consumable-group-db.service'; +import { ConsumableGroupEntity } from './consumable-group/consumable-group.entity'; +import { ConsumableController } from './consumable/consumable.controller'; +import { ConsumableService } from './consumable/consumable.service'; +import { ConsumableDbService } from './consumable/consumable-db.service'; +import { ConsumableEntity } from './consumable/consumable.entity'; +import { LocationEntity } from '../base/location/location.entity'; +import { ConsumableLocationEntity } from './consumable/consumable-location.entity'; +import { LocationModule } from '../base/location/location.module'; +import { DeviceImageEntity } from './device/device-image.entity'; @Module({ imports: [ @@ -19,9 +31,21 @@ import { DeviceEntity } from './device/device.entity'; DeviceTypeEntity, DeviceGroupEntity, DeviceEntity, + ConsumableGroupEntity, + ConsumableEntity, + ConsumableLocationEntity, + LocationEntity, + DeviceImageEntity, ]), + LocationModule, + ], + controllers: [ + DeviceTypeController, + DeviceGroupController, + DeviceController, + ConsumableGroupController, + ConsumableController, ], - controllers: [DeviceTypeController, DeviceGroupController, DeviceController], providers: [ DeviceTypeService, DeviceTypeDbService, @@ -29,6 +53,11 @@ import { DeviceEntity } from './device/device.entity'; DeviceGroupDbService, DeviceService, DeviceDbService, + ConsumableGroupService, + ConsumableGroupDbService, + ConsumableService, + ConsumableDbService, ], + exports: [DeviceService], }) export class InventoryModule {} diff --git a/backend/src/shared/dto/download-url.dto.ts b/backend/src/shared/dto/download-url.dto.ts new file mode 100644 index 0000000..4007972 --- /dev/null +++ b/backend/src/shared/dto/download-url.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class DownloadUrlDto { + @ApiProperty() + @Expose() + url: string; +} diff --git a/backend/src/shared/dto/filename.dto.ts b/backend/src/shared/dto/filename.dto.ts new file mode 100644 index 0000000..332bda1 --- /dev/null +++ b/backend/src/shared/dto/filename.dto.ts @@ -0,0 +1,9 @@ +import { IsDefined, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class FilenameDto { + @ApiProperty() + @IsDefined() + @IsString() + contentType: string; +} diff --git a/backend/src/shared/dto/image-download-querys.dto.ts b/backend/src/shared/dto/image-download-querys.dto.ts new file mode 100644 index 0000000..8e86686 --- /dev/null +++ b/backend/src/shared/dto/image-download-querys.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsIn } from 'class-validator'; +import { ImageService } from '../../core/services/storage/image.service'; + +export class ImageDownloadQuerysDto { + @ApiProperty() + @IsDefined() + @IsIn([...ImageService.sizes.map((x) => 'webp-' + x), '', 'webp-600-blur']) + size: string; +} diff --git a/backend/src/shared/dto/image-id-guid.dto.ts b/backend/src/shared/dto/image-id-guid.dto.ts new file mode 100644 index 0000000..e4a539a --- /dev/null +++ b/backend/src/shared/dto/image-id-guid.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsUUID } from 'class-validator'; + +/** + * DTO for an ID that is a GUID. + */ +export class ImageIdGuidDto { + @ApiProperty() + @IsDefined() + @IsUUID() + imageId: string; +} diff --git a/backend/src/shared/dto/upload-url.dto.ts b/backend/src/shared/dto/upload-url.dto.ts new file mode 100644 index 0000000..d772a85 --- /dev/null +++ b/backend/src/shared/dto/upload-url.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; + +class FormData { + @ApiProperty() + @Expose() + bucket: string; + + @ApiProperty() + @Expose() + key: string; + + @ApiProperty() + @Expose() + 'Content-Type': string; + + @ApiProperty() + @Expose() + 'x-amz-date': string; + + @ApiProperty() + @Expose() + 'x-amz-algorithm': string; + + @ApiProperty() + @Expose() + 'x-amz-credential': string; + + @ApiProperty() + @Expose() + 'x-amz-signature': string; + + @ApiProperty() + @Expose() + policy: string; +} + +export class UploadUrlDto { + @ApiProperty() + @Expose() + postURL: string; + + @ApiProperty() + @Expose() + @Type(() => FormData) + formData: FormData; + + @ApiProperty() + @Expose() + id: string; +} diff --git a/backend/src/shared/interceptor/custom-class-serializer.interceptor.ts b/backend/src/shared/interceptor/custom-class-serializer.interceptor.ts index ab29c62..cf2fea5 100644 --- a/backend/src/shared/interceptor/custom-class-serializer.interceptor.ts +++ b/backend/src/shared/interceptor/custom-class-serializer.interceptor.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class CustomClassSerializerInterceptor extends ClassSerializerInterceptor { - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept(context: ExecutionContext, next: CallHandler): Observable { // Skip validation on health requests // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call if (context.switchToHttp()?.getRequest()?.url?.startsWith('/health')) { diff --git a/dev.env b/dev.env index d977f5b..1e7974a 100644 --- a/dev.env +++ b/dev.env @@ -20,3 +20,7 @@ POSTGRES_PASSWORD= POSTGRES_USER=admin POSTGRES_MANAGER_PASSWORD= POSTGRES_KEYCLOAK_PASSWORD= + +RABBITMQ_VERSION=3-management +RABBITMQ_DEFAULT_USER=root +RABBITMQ_DEFAULT_PASS=samplePassword diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c4c728c..95d9107 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,10 +20,23 @@ services: environment: - MINIO_ROOT_USER=${MINIO_ROOT_USER} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} + - MINIO_NOTIFY_AMQP_ENABLE_AMQP=on + - MINIO_NOTIFY_AMQP_URL_AMQP=amqp://${RABBITMQ_DEFAULT_USER}:${RABBITMQ_DEFAULT_PASS}@rabbitmq:5672/ + - MINIO_NOTIFY_AMQP_EXCHANGE_AMQP=file + - MINIO_NOTIFY_AMQP_EXCHANGE_TYPE_AMQP=fanout + - MINIO_NOTIFY_AMQP_MANDATORY_AMQP=on + - MINIO_NOTIFY_AMQP_DURABLE_AMQP=on + - MINIO_NOTIFY_AMQP_NO_WAIT_AMQP=off + - MINIO_NOTIFY_AMQP_INTERNAL_AMQP=off + - MINIO_NOTIFY_AMQP_AUTO_DELETED_AMQP=off + - MINIO_NOTIFY_AMQP_DELIVERY_MODE_AMQP=2 + - MINIO_NOTIFY_AMQP_QUEUE_LIMIT_AMQP=10000 volumes: - minio:/data + - ./docker/certs:/root/.minio/certs command: server /data --console-address ":9001" networks: + - queue - storage keycloak: @@ -119,7 +132,35 @@ services: networks: - database + rabbitmq: + image: rabbitmq:${RABBITMQ_VERSION} + hostname: rabbitmq + healthcheck: + test: [ "CMD", "rabbitmq-diagnostics", "-q", "ping" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + start_interval: 5s + deploy: + resources: + limits: + cpus: '1' + memory: 512M + ports: + - "15672:15672" + - "5672:5672" + restart: always + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + volumes: + - rabbitmq:/var/lib/rabbitmq/mnesia/ + networks: + - queue + networks: + queue: database: storage: identity: diff --git a/docker/certs/private.key b/docker/certs/private.key new file mode 100644 index 0000000..6997df9 --- /dev/null +++ b/docker/certs/private.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDE6pAm78Q+UE4s +8GK0Ga/X+b1Spfcr3g0Lf6aB7rXOS9vjrIDQDO++QhtT4SDit96FME/cR6jIA/TP +gPmtW3wqVyGtyYRqe6MeLGVQEjqQk4Hn0CX4hyPgQgvuDoXpqQUpzCvpJQ9xEXNh +OZp/6xd8zaf+LUdh344GZE5l+sryWVYWm5rxTJp8gt1WG39vZmd2i7vc1UYbFeNg +Z7oNzoEJLfM8CvsY2WnvXFP/chQtxm1jBDDu2ltvu/peiOnk+0tIXk/l9QEnQWt5 +n5e0T+wcOAs92hlZwhHnzWYdnhH6Pyd95g5DiotPRgpLWprUHxJ9YznRRkrPZF6t +53+rDQXqNz74Sf2M98XbJ+3j1vrdqikXkiAtUVX7b7XJwS9CNFx2C0E7H8CNIoJx +tzx1GP3MQwY0EWtaNnSiPMUFjdOTTjCoJ1XFwPvdGTJIDHnsJdJlIiqAJYfXRdiF +6b8PPM85LDIhcfF/zAPNUU2kvyn2xtD7++gEybPd98lk2qdlAczOUN8jAuCvfxw4 +dGKzpixM1DzP1ctPTXrjN8w9IigNmZWveOrwHSHcEstD6g4vMxp4X64o2YBpTjrp +ll+q9bYYeWsOCx0LlS0Q6CpJ85GvgmcSOWrrr4qEGIygzvusscoly7tKJq8AxOl6 +NEnVxZUwGKQzMlSjbkbOIxHsehiLIQIDAQABAoICABFIZQ6FzLuLYNEg6AjWmFBk +YvF2D5OSEaMIuRx+TwakMdBxu3yHJiOUucFK6Q/9A1K9QsUapP2pGzt7Hm7QsL0m +mJYgMbcG0vI7A0lb0DgQOj6WTj7Z3ZQ5N8LVE3vGkeVxPglgb1KFLZNC8wR3JcCW +bEAqyTEV5ek5tIfO0zEiFiQ11AuJpaV39uUv1Kd6XWpSKVLghR6rdSFo+TNtBHZB +yi9i51bu/hU8DUNGR+8ck001ePX9xDiyTu8tJRor1Bet72VHc6p2W3B5SV9SBG8V +nCb5lXADUH7/0A6ZaQqFsHmkT2wuJLv4cb7bXOtxLrZClzh+6uH49TZeMx5YLkSQ +2PmrTC460+n59kBiUPnGXn/4s9yNbB2wei9XU43DISm4iW3LaTlLU01M04fwkWC/ +ha0TFNXsNmAUApqXC5rrYyfP9c3USlaZ/g21RjP8uw4fJlHlkdeosYFYq3gmIFwe +RiVoUAlhZXTofI5aEeLWDhmt4TazNEw5PFfsb8T7rEy3x9B1MXwavl8+3d+Ta2Ng +nk2tIUdb13OWacFj9evf8jpV262kuEYGPRkrXpigV2HMd0VJPVDeo9wLMPDFfiZ2 +89702b1WHPqtwa0AALbY6mhv8yeR/M69/oOG0FbzIFT97k4HAgX9la2gDhkxuKly +wXgtstfm03Xnwoe6EalhAoIBAQDlVQ5vj3zY+yM1rZpyDtKD671eoqDm9rl8ZhCW +ytzHFm82DQkE20tbYaWsiWdFyB/+6GmxeU7XXRZQs2lsntf6MG2JnURBcFvbIxTm +EcQm7xmySeGiX+PHLNxweoezZ64vdWdqIcYp3nbsPUX44Ly5eedZgZDMlLFZ6HVf +KSlQYWR/0vJyhpHNxyS1Ej591rq9fmrF6JJfPmPJl3Y5Hx3BuTqPQAcmX7XtaCXK +C5I6P1r7y1Wv8ANdB9pb+DVh2JkuoS1KUn8cwAyl6TAI2VA/cl9udt81VPwQQ3iW +hH+0h70WcBTtPJUm1soWez8w94/JNci1C/mS2mo9AI6tlcMrAoIBAQDb0IWc7dEk +v9K+nRyBs5sF063ojJ/w7eUsSPdoN06ywErDQt43x37Q5JjDzvhHlFLbgf335NNs +yWFyLif75qtZyIrx2sj4mDGmh+oC+9xh6ZOd91wdKMleN9reK+zwoudD2LDXgJtO +K3c+MHdnNcgeY43ezMqrGMp06FxmJM0qED9D2Z63blpjmmsaPDtrfu0PMR5pTP9I +udpRe/7pa+0W92o6OSyYf3INCWtd+S2TYZtCuc0WOLdNNLhk6IdzKBd0V63OSkX6 +oxnbKXqwEtwpntSDOjZDLrvkFz4O/dy5SSswVyMveazKxaNmRoV/hrKsOwOgwR1/ +xNYMJVP7F3TjAoIBAQDRB4kD6H58a9P4/kaTBa2d7saJtqQAQQxqNcGTIE7B7FHr +q0/4LEXwgf13WTpXYYTAXGjSCebx5/gKEK3cAqCLe46r6zumhdpD0CMhXToz3qXG +Ww8daFd+WQaIQzbjMHKU8WcUVrp/uTUeOO9JXNbIHDPh4nXv8uwALiClXyg4Cr2G +wOiZuMy3CngLzxhErO9C/zIlN8oKpBxiR/rLL/B4ffPBVDPwJzb0sIQZOBjNnKe6 +b+inV5ZJOnoub/uANuPQm7pjTvRraSVeKEDPH/zEB+SyFAl5W//wdv83+odILp0M +EZcRcbHlV8uVWDsNz+gwFyTc2JBf6VMCTTq/P41HAoIBAA9ikeeA8bF7x5lVz8f4 +NTJ8NWDgbtVjITYvSTm/HT//m3v9MyZ+TQ774QFbfB8ub3ozp/3wwyeLFMn0FxJX +e8jF84un/4b+yALa4nMhA7TKr21QAd98mlOA303Lj0Lsc/lYsk/zDWu0OR1eMQ1F +Q2N1HlnoxYqiKpFyLf1sN/votTTfh29ZRvRPu41Th+knMhptGq7OF9QURgaMAjR+ +PFLuMD4xAEEQMoBdF2m1Zg45t6885/DVOWcq+Hj/mXNi6/lVpbGZmzpGrimbxp2K +RGSZXFBvA5tCKx50zgAonolNaLtybeEFyCVNHfmrl+5sFBdf7goTWig2M7EX77/U +TXcCggEAW+7j1QK+/vgjkk4x/J7Ktbr+A1QlkcRrLyqT0zl/bUlFBYQHCrtQOu6y +1hsprUDl5/WoRNuOTkMIKG5QL47kCCNawSwjz69q28aEOMcfdy6YpBJ9knDYXyPh +YAUJnlzfR2SiQbNvUM+HC0clbrdDwHF8GILq5sgHZUGoF3sAjfIMbhN1KgwwjH94 +xDlZLLh34vH2kMTcPDKeVCkrCqNAzmQF9F7wY8sfHPTfe4FLCBcCgW3aT+PcokxI +aOnCaOoCnALy8acnlfjaAQqRXXbh4Qj2xuzzNigIqWRCEsV3f5daKlT8h4A8+yJB +0276y1eDJkOIBMOqAiH9Z2xNflxLkg== +-----END PRIVATE KEY----- diff --git a/docker/certs/public.crt b/docker/certs/public.crt new file mode 100644 index 0000000..25e2253 --- /dev/null +++ b/docker/certs/public.crt @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFiTCCA3GgAwIBAgIUMm32dv/N2ofF+utW+XZEXvfXc74wDQYJKoZIhvcNAQEL +BQAwVDELMAkGA1UEBhMCREUxDTALBgNVBAgMBE5vbmUxFDASBgNVBAcMC0VudGVu +aGF1c2VuMQwwCgYDVQQKDANEaXMxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNTAz +MTQxNjMxMTZaFw0yNjAzMTQxNjMxMTZaMFQxCzAJBgNVBAYTAkRFMQ0wCwYDVQQI +DAROb25lMRQwEgYDVQQHDAtFbnRlbmhhdXNlbjEMMAoGA1UECgwDRGlzMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDE +6pAm78Q+UE4s8GK0Ga/X+b1Spfcr3g0Lf6aB7rXOS9vjrIDQDO++QhtT4SDit96F +ME/cR6jIA/TPgPmtW3wqVyGtyYRqe6MeLGVQEjqQk4Hn0CX4hyPgQgvuDoXpqQUp +zCvpJQ9xEXNhOZp/6xd8zaf+LUdh344GZE5l+sryWVYWm5rxTJp8gt1WG39vZmd2 +i7vc1UYbFeNgZ7oNzoEJLfM8CvsY2WnvXFP/chQtxm1jBDDu2ltvu/peiOnk+0tI +Xk/l9QEnQWt5n5e0T+wcOAs92hlZwhHnzWYdnhH6Pyd95g5DiotPRgpLWprUHxJ9 +YznRRkrPZF6t53+rDQXqNz74Sf2M98XbJ+3j1vrdqikXkiAtUVX7b7XJwS9CNFx2 +C0E7H8CNIoJxtzx1GP3MQwY0EWtaNnSiPMUFjdOTTjCoJ1XFwPvdGTJIDHnsJdJl +IiqAJYfXRdiF6b8PPM85LDIhcfF/zAPNUU2kvyn2xtD7++gEybPd98lk2qdlAczO +UN8jAuCvfxw4dGKzpixM1DzP1ctPTXrjN8w9IigNmZWveOrwHSHcEstD6g4vMxp4 +X64o2YBpTjrpll+q9bYYeWsOCx0LlS0Q6CpJ85GvgmcSOWrrr4qEGIygzvusscol +y7tKJq8AxOl6NEnVxZUwGKQzMlSjbkbOIxHsehiLIQIDAQABo1MwUTAdBgNVHQ4E +FgQUIrN/OHzZCXIeGOHShlQ6h7z/RiEwHwYDVR0jBBgwFoAUIrN/OHzZCXIeGOHS +hlQ6h7z/RiEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAAmNG +kVfy0wADV5AD5/vffgrM6NIIN304r5wW/n82IPy+bdWKWxKFXdBcVMlGA9vz3oZV +fsd2PH3cihRCU5NrJ6TcPF7oD6Z8cS5XmZPUc9JLZoGlko5mjkpeXMqBfDIUG7Fs +n8lWRnSR6SyCD6opcls012bv41pxAtumhDci8bBGk3BDROflvBdFavPmVYVwmcMI +a+WqD1yUKseVmA0kt1GXv+fVNoYRXFzS4RUh6UM/qDt5TsgjMHR9fKWakz45AkHq +H5qYz5mkCtTg527DFPwlOQg8ma1gZ3hbCPDJ072VWQdo3sVTC04WLKPvhBpCkP/7 +80939lieRkA4RLCwVZy4Psjlvxg+k9kBKODZ1OYIZg7fSEa1Xxuiy1cpj5xwoq3d +qRbkdMZWkSw3VzQs3+8/hkrINYeqgufc/4bRDC2NHHxNfey+Crxx0R6YYvnztpD7 +ToJz+sj8z03OTb+aWNjrB2A/XhYkJppNsImdmK+NLwpo8jV9+Ue1Xdlz/KVJRoLP +VvOQMRhOtU2ww9dAocvh3GApEIvA3e2xBY1/LJ6HeJhDoqadoWdAM3uF6w3ZOmk6 +higJARcZP9c4qIYCtaT0WLm4OkTlCsaT/IE9HQY89q/q59D9KlL4/Buho1ED7xt/ +XhPnh7CvLSBBSZTfaYVUPjRqa9u69rynb8KVClM= +-----END CERTIFICATE----- diff --git a/frontend/angular.json b/frontend/angular.json index 8232b5c..4016e2f 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -47,8 +47,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumWarning": "2MB", + "maximumError": "3MB" }, { "type": "anyComponentStyle", diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 785d051..bb595e5 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -40,6 +40,12 @@

Manager

  • Geräte-Gruppen
  • +
  • + Verbrauchsmaterial +
  • +
  • + Verbrauchsmaterial-Gruppen +
  • diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 856b897..33550cc 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,7 +1,7 @@ import { ApplicationConfig, provideZoneChangeDetection, - importProvidersFrom, provideAppInitializer, inject, + importProvidersFrom, provideAppInitializer, inject, LOCALE_ID, } from '@angular/core'; import {provideRouter} from '@angular/router'; @@ -22,8 +22,10 @@ import {authModuleConfig} from './core/auth/auth-module-config'; import {de as deDE} from 'date-fns/locale'; import {registerLocaleData} from '@angular/common'; import {provideAppConfigs} from './app.configs'; +import localeDe from '@angular/common/locales/de'; -registerLocaleData(de); +// registerLocaleData(de); +registerLocaleData(localeDe); // We need a factory since localStorage is not available at AOT build time export function storageFactory(): OAuthStorage { @@ -39,6 +41,7 @@ export const appConfig: ApplicationConfig = { {provide: AuthConfig, useValue: authConfig}, {provide: OAuthStorage, useFactory: storageFactory}, {provide: NZ_DATE_LOCALE, useValue: deDE}, + { provide: LOCALE_ID, useValue: 'de' }, provideNzI18n(de_DE), provideOAuthClient(authModuleConfig), provideHttpClient(withInterceptorsFromDi()), diff --git a/frontend/src/app/app.configs.ts b/frontend/src/app/app.configs.ts index 5314b67..d356f91 100644 --- a/frontend/src/app/app.configs.ts +++ b/frontend/src/app/app.configs.ts @@ -1,5 +1,8 @@ import {EnvironmentProviders, InjectionToken, makeEnvironmentProviders} from '@angular/core'; +// TODO refresh interval un image upload +// TODO number of refreshes before image upload is considered as failed + export const SEARCH_DEBOUNCE_TIME = new InjectionToken('SEARCH_DEBOUNCE_TIME'); export const SELECT_ITEMS_COUNT = new InjectionToken('SELECT_ITEMS_COUNT'); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.html new file mode 100644 index 0000000..3915e80 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.html @@ -0,0 +1,40 @@ +

    Verbrauchsmaterial-Gruppe erstellen

    + +
    + + Name + + + + @if (control.errors?.['required']) { + Bitte einen Namen eingeben. + } + @if (control.errors?.['minlength']) { + Bitte mindestens 1 Zeichen eingeben. + } + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Weitere Informationen + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 2000 Zeichen eingeben. + } + + + + + + + + + +
    diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.spec.ts new file mode 100644 index 0000000..7d8a1ff --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConsumableGroupCreateComponent } from './consumable-group-create.component'; + +describe('ConsumableGroupCreateComponent', () => { + let component: ConsumableGroupCreateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConsumableGroupCreateComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ConsumableGroupCreateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.ts new file mode 100644 index 0000000..83dc9e1 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.ts @@ -0,0 +1,57 @@ +import {Component, OnDestroy, Signal} from '@angular/core'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {NzButtonModule} from 'ng-zorro-antd/button'; +import {NzFormModule} from 'ng-zorro-antd/form'; +import {NzInputModule} from 'ng-zorro-antd/input'; +import {Subject, takeUntil} from 'rxjs'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import {ConsumableGroupCreateService} from './consumable-group-create.service'; + +interface ConsumableGroupCreateForm { + name: FormControl; + notice?: FormControl; +} + +@Component({ + selector: 'ofs-consumable-group-create', + imports: [ReactiveFormsModule, NzButtonModule, NzFormModule, NzInputModule], + standalone: true, + templateUrl: './consumable-group-create.component.html', + styleUrl: './consumable-group-create.component.less' +}) +export class ConsumableGroupCreateComponent implements OnDestroy { + form = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(1), Validators.maxLength(100)] + }), + notice: new FormControl('', { + validators: [Validators.maxLength(2000)] + }), + }); + + createLoading: Signal; + private destroy$ = new Subject(); + + constructor( + private readonly service: ConsumableGroupCreateService, + private readonly inAppMessagingService: InAppMessageService + ) { + this.createLoading = this.service.createLoading; + + this.service.createLoadingError + .pipe(takeUntil(this.destroy$)) + .subscribe((x) => this.inAppMessagingService.showError(x)); + this.service.createLoadingSuccess + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.inAppMessagingService.showSuccess('Änderungen gespeichert')); + } + + submit() { + this.service.create(this.form.getRawValue()); + } + + ngOnDestroy(): void { + this.destroy$.next(); + } +} diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.spec.ts new file mode 100644 index 0000000..af5f167 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ConsumableGroupCreateService } from './consumable-group-create.service'; + +describe('ConsumableGroupCreateService', () => { + let service: ConsumableGroupCreateService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ConsumableGroupCreateService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.ts new file mode 100644 index 0000000..b117219 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.ts @@ -0,0 +1,37 @@ +import {Injectable, signal} from '@angular/core'; +import {Subject} from 'rxjs'; +import {Router} from '@angular/router'; +import {ConsumableGroupService} from '@backend/api/consumableGroup.service'; +import {HttpErrorResponse} from '@angular/common/http'; +import {ConsumableGroupCreateDto} from '@backend/model/consumableGroupCreateDto'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumableGroupCreateService { + createLoading = signal(false); + createLoadingError = new Subject(); + createLoadingSuccess = new Subject(); + + constructor( + private readonly apiService: ConsumableGroupService, + private readonly router: Router, + ) { + } + + create(rawValue: ConsumableGroupCreateDto) { + this.createLoading.set(true); + this.apiService.consumableGroupControllerCreate({consumableGroupCreateDto: rawValue}) + .subscribe({ + next: (entity) => { + this.createLoading.set(false); + this.createLoadingSuccess.next(); + this.router.navigate(['inventory', 'consumable-groups', entity.id]); + }, + error: (err: HttpErrorResponse) => { + this.createLoading.set(false); + this.createLoadingError.next(err.status === 400 ? err.error.message : 'Fehler beim speichern.'); + }, + }); + } +} diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.html new file mode 100644 index 0000000..66bea35 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.html @@ -0,0 +1,51 @@ +

    Verbrauchsmaterial-Gruppe bearbeiten

    + +

    Der Verbrauchsmaterial-Gruppe wurde nicht gefunden!

    +
    + + Verbrauchsmaterial-Gruppe wird geladen... + + +
    + + Name + + + + @if (control.errors?.['required']) { + Bitte einen Namen eingeben. + } + @if (control.errors?.['minlength']) { + Bitte mindestens 1 Zeichen eingeben. + } + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Weitere Informationen + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 2000 Zeichen eingeben. + } + + + + + + + + + + +
    + +
    diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.spec.ts new file mode 100644 index 0000000..bf31ab2 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConsumableGroupDetailComponent } from './consumable-group-detail.component'; + +describe('ConsumableGroupDetailComponent', () => { + let component: ConsumableGroupDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConsumableGroupDetailComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ConsumableGroupDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.ts new file mode 100644 index 0000000..1942929 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.ts @@ -0,0 +1,103 @@ +import {Component, effect, OnDestroy, OnInit, Signal} from '@angular/core'; +import {NgIf} from '@angular/common'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {NzButtonModule} from 'ng-zorro-antd/button'; +import {NzFormModule} from 'ng-zorro-antd/form'; +import {NzInputModule} from 'ng-zorro-antd/input'; +import {NzCheckboxModule} from 'ng-zorro-antd/checkbox'; +import {NzPopconfirmModule} from 'ng-zorro-antd/popconfirm'; +import {NzSelectModule} from 'ng-zorro-antd/select'; +import {NzInputNumberModule} from 'ng-zorro-antd/input-number'; +import {Subject, takeUntil} from 'rxjs'; +import {ActivatedRoute} from '@angular/router'; +import {ConsumableGroupDetailService} from './consumable-group-detail.service'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; + +interface ConsumableGroupDetailForm { + name: FormControl; + notice?: FormControl; +} + +@Component({ + selector: 'ofs-consumable-group-detail', + imports: [NgIf, ReactiveFormsModule, NzButtonModule, NzFormModule, NzInputModule, NzCheckboxModule, NzPopconfirmModule, NzSelectModule, NzInputNumberModule + ], + standalone: true, + templateUrl: './consumable-group-detail.component.html', + styleUrl: './consumable-group-detail.component.less' +}) +export class ConsumableGroupDetailComponent implements OnInit, OnDestroy { + notFound: Signal; + loading: Signal; + loadingError: Signal; + updateLoading: Signal; + deleteLoading: Signal; + + private destroy$ = new Subject(); + + form = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(1), Validators.maxLength(100)] + }), + notice: new FormControl('', { + validators: [Validators.maxLength(2000)] + }), + }); + + constructor( + private readonly activatedRoute: ActivatedRoute, + private readonly service: ConsumableGroupDetailService, + private readonly inAppMessagingService: InAppMessageService, + ) { + this.notFound = this.service.notFound; + this.loading = this.service.loading; + this.loadingError = this.service.loadingError; + this.deleteLoading = this.service.deleteLoading; + this.updateLoading = this.service.updateLoading; + + effect(() => { + const entity = this.service.entity(); + if (entity) this.form.patchValue(entity); + }); + + effect(() => { + const updateLoading = this.service.loadingError(); + if (updateLoading) { + this.inAppMessagingService.showError('Fehler beim laden der Verbrauchsmaterial-Gruppe.'); + } + }); + + this.service.deleteLoadingError + .pipe(takeUntil(this.destroy$)) + .subscribe((x) => this.inAppMessagingService.showError(x)); + this.service.deleteLoadingSuccess + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.inAppMessagingService.showSuccess('Verbrauchsmaterial-Gruppe gelöscht')); + this.service.updateLoadingError + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.inAppMessagingService.showError('Fehler beim speichern.')); + this.service.updateLoadingSuccess + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.inAppMessagingService.showSuccess('Änderungen gespeichert')); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.activatedRoute.params.subscribe(params => { + this.service.load(params['id']); + }); + } + + submit() { + this.service.update(this.form.getRawValue()); + } + + delete() { + this.service.delete(); + } +} diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.spec.ts new file mode 100644 index 0000000..28041bf --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ConsumableGroupDetailService } from './consumable-group-detail.service'; + +describe('ConsumableGroupDetailService', () => { + let service: ConsumableGroupDetailService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ConsumableGroupDetailService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.ts new file mode 100644 index 0000000..f212e5a --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.ts @@ -0,0 +1,89 @@ +import {Injectable, signal} from '@angular/core'; +import {Subject} from 'rxjs'; +import {ConsumableGroupDto} from '@backend/model/consumableGroupDto'; +import {Router} from '@angular/router'; +import {ConsumableGroupService} from '@backend/api/consumableGroup.service'; +import {HttpErrorResponse} from '@angular/common/http'; +import {DeviceGroupUpdateDto} from '@backend/model/deviceGroupUpdateDto'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumableGroupDetailService { + id?: number; + entity = signal(null); + loading = signal(false); + loadingError = signal(false); + notFound = signal(false); + deleteLoading = signal(false); + updateLoading = signal(false); + updateLoadingError = new Subject(); + deleteLoadingError = new Subject(); + updateLoadingSuccess = new Subject(); + deleteLoadingSuccess = new Subject(); + + constructor( + private readonly service: ConsumableGroupService, + private readonly router: Router, + ) { + } + + load(id: number) { + this.id = id; + this.loading.set(true); + this.service.consumableGroupControllerGetOne({id}) + .subscribe({ + next: (newEntity) => { + this.entity.set(newEntity); + this.loadingError.set(false); + this.loading.set(false); + }, + error: (err: HttpErrorResponse) => { + if (err.status === 404) { + this.notFound.set(true); + } + this.entity.set(null); + this.loadingError.set(true); + this.loading.set(false); + } + }); + } + + update(rawValue: DeviceGroupUpdateDto) { + const entity = this.entity(); + if (entity) { + this.updateLoading.set(true); + this.service.consumableGroupControllerUpdate({id: entity.id, consumableGroupUpdateDto: rawValue}) + .subscribe({ + next: (newEntity) => { + this.updateLoading.set(false); + this.entity.set(newEntity); + this.updateLoadingSuccess.next(); + }, + error: () => { + this.updateLoading.set(false); + this.updateLoadingError.next(); + }, + }); + } + } + + delete() { + const entity = this.entity(); + if (entity) { + this.deleteLoading.set(true); + this.service.consumableGroupControllerDelete({id: entity.id}) + .subscribe({ + next: () => { + this.deleteLoading.set(false); + this.deleteLoadingSuccess.next(); + this.router.navigate(['inventory', 'consumable-groups']); + }, + error: (err: HttpErrorResponse) => { + this.deleteLoading.set(false); + this.deleteLoadingError.next(err.status === 400 ? err.error : 'Fehler beim löschen'); + }, + }); + } + } +} diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.html new file mode 100644 index 0000000..7dfc35b --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.html @@ -0,0 +1,23 @@ + + + + Name + + + + + + {{ entity.name }} + + Details + + + + diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.spec.ts new file mode 100644 index 0000000..ecf0fb0 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConsumableGroupListComponent } from './consumable-group-list.component'; + +describe('ConsumableGroupListComponent', () => { + let component: ConsumableGroupListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConsumableGroupListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ConsumableGroupListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.ts new file mode 100644 index 0000000..d6bd52e --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.ts @@ -0,0 +1,32 @@ +import {Component} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import { + NzTableModule, NzTableQueryParams, +} from 'ng-zorro-antd/table'; +import {ConsumableGroupsService} from '../consumable-groups.service'; +import {HasRoleDirective} from "../../../../core/auth/has-role.directive"; +import {NzButtonComponent} from "ng-zorro-antd/button"; +import {RouterLink} from "@angular/router"; + +@Component({ + selector: 'ofs-consumable-group-list', + imports: [ + NzTableModule, + CommonModule, + HasRoleDirective, + NzButtonComponent, + RouterLink + ], + standalone: true, + templateUrl: './consumable-group-list.component.html', + styleUrl: './consumable-group-list.component.less' +}) +export class ConsumableGroupListComponent { + constructor(public service: ConsumableGroupsService) {} + + onQueryParamsChange(params: NzTableQueryParams) { + const {pageSize, pageIndex, sort} = params; + const sortCol = sort.find(x => x.value); + this.service.updatePage(pageIndex, pageSize, sortCol?.key, sortCol?.value === 'ascend' ? 'ASC' : 'DESC'); + } +} diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html new file mode 100644 index 0000000..2646c54 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html @@ -0,0 +1,17 @@ +

    Verbrauchsmaterial-Gruppen

    +
    +
    + +
    +
    + + + + + + +
    +
    + diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.spec.ts new file mode 100644 index 0000000..84bc397 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeviceGroupsComponent } from './device-groups.component'; + +describe('DeviceGroupsComponent', () => { + let component: DeviceGroupsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeviceGroupsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeviceGroupsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.ts new file mode 100644 index 0000000..8c72e7e --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.ts @@ -0,0 +1,40 @@ +import {Component, OnInit} from '@angular/core'; +import {HasRoleDirective} from '../../../core/auth/has-role.directive'; +import {NzButtonModule} from 'ng-zorro-antd/button'; +import {NzWaveDirective} from 'ng-zorro-antd/core/wave'; +import {RouterLink} from '@angular/router'; +import {ConsumableGroupListComponent} from './consumable-group-list/consumable-group-list.component'; +import {NzGridModule} from 'ng-zorro-antd/grid'; +import {NzInputModule} from 'ng-zorro-antd/input'; +import {NzIconModule} from 'ng-zorro-antd/icon'; +import {ConsumableGroupsService} from './consumable-groups.service'; + +@Component({ + selector: 'ofs-consumable-groups', + imports: [ + HasRoleDirective, + NzWaveDirective, + RouterLink, + ConsumableGroupListComponent, + NzButtonModule, + NzGridModule, + NzInputModule, + NzIconModule, + ], + standalone: true, + templateUrl: './consumable-groups.component.html', + styleUrl: './consumable-groups.component.less' +}) +export class ConsumableGroupsComponent implements OnInit { + constructor(private service: ConsumableGroupsService) { + } + + search($event: Event) { + const target = $event.target as HTMLInputElement; + this.service.search(target.value); + } + + ngOnInit(): void { + this.service.init(); + } +} diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.spec.ts new file mode 100644 index 0000000..d282986 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { DeviceGroupsService } from './device-groups.service'; + +describe('DeviceGroupsService', () => { + let service: DeviceGroupsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DeviceGroupsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts new file mode 100644 index 0000000..5230596 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts @@ -0,0 +1,80 @@ +import {Inject, Injectable, signal} from '@angular/core'; +import {ConsumableGroupDto} from '@backend/model/consumableGroupDto'; +import {ConsumableGroupService} from '@backend/api/consumableGroup.service'; +import {BehaviorSubject, debounceTime, distinctUntilChanged, filter} from 'rxjs'; +import {SEARCH_DEBOUNCE_TIME} from '../../../app.configs'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumableGroupsService { + entities = signal([]); + page = 1; + itemsPerPage = 20; + total = signal(0); + entitiesLoading = signal(false); + entitiesLoadError = signal(false); + sortCol?: string; + sortDir?: string; + searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); + private searchTerm?: string; + + constructor( + private readonly apiService: ConsumableGroupService, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + ) { + this.searchTerm$ + .pipe( + filter(x => x.propagate), + debounceTime(time), + distinctUntilChanged(), + ) + .subscribe(term => { + this.searchTerm = term.value; + this.load(); + }); + } + + load() { + this.entitiesLoading.set(true); + this.apiService.consumableGroupControllerGetCount({searchTerm: this.searchTerm}) + .subscribe((count) => this.total.set(count.count)); + this.apiService.consumableGroupControllerGetAll({ + limit: this.itemsPerPage, + offset: (this.page - 1) * this.itemsPerPage, + sortCol: this.sortCol, + sortDir: (this.sortDir === 'ASC' || this.sortDir === 'DESC') ? this.sortDir : undefined, + searchTerm: this.searchTerm + }) + .subscribe({ + next: (entities) => { + this.entities.set(entities); + this.entitiesLoadError.set(false); + this.entitiesLoading.set(false); + }, + error: () => { + this.entities.set([]); + this.entitiesLoadError.set(true); + this.entitiesLoading.set(false); + } + }); + } + + updatePage(page: number, itemsPerPage: number, sortCol?: string, sortDir?: string) { + this.page = page; + this.itemsPerPage = itemsPerPage; + this.sortCol = sortCol; + this.sortDir = this.sortCol ? sortDir : undefined; + this.load(); + } + + search(term: string) { + this.searchTerm$.next({propagate: true, value: term}); + this.page = 1; + } + + init() { + this.searchTerm = ''; + this.searchTerm$.next({propagate: false, value: ''}); + } +} diff --git a/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.html b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.html new file mode 100644 index 0000000..e10c978 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.html @@ -0,0 +1,64 @@ +

    Verbrauchsmaterial erstellen

    + +
    + + Name + + + + @if (control.errors?.['required']) { + Bitte einen Namen eingeben. + } + @if (control.errors?.['minlength']) { + Bitte mindestens 1 Zeichen eingeben. + } + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Verbrauchsmaterial-Gruppe + + + @if (consumableGroupsIsLoading()) { + + + Laden... + + } @else { + @for (item of consumableGroups(); track item) { + + } + } + + + + + + Weitere Informationen + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 2000 Zeichen eingeben. + } + + + + + + + + + +
    diff --git a/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.less b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.spec.ts b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.spec.ts new file mode 100644 index 0000000..26d311b --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConsumableCreateComponent } from './consumable-create.component'; + +describe('ConsumableCreateComponent', () => { + let component: ConsumableCreateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConsumableCreateComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ConsumableCreateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.ts b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.ts new file mode 100644 index 0000000..08135c1 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.ts @@ -0,0 +1,83 @@ +import {Component, OnDestroy, Signal} from '@angular/core'; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms'; +import {Subject, takeUntil} from 'rxjs'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import {ConsumableCreateService} from './consumable-create.service'; +import {NzInputModule} from 'ng-zorro-antd/input'; +import {NzButtonModule} from 'ng-zorro-antd/button'; +import {NzGridModule} from 'ng-zorro-antd/grid'; +import {NzFormModule} from 'ng-zorro-antd/form'; +import {ConsumableGroupDto} from '@backend/model/consumableGroupDto'; +import {NzOptionComponent, NzSelectComponent} from 'ng-zorro-antd/select'; +import {NzIconModule} from 'ng-zorro-antd/icon'; + +interface ConsumableCreateForm { + name: FormControl; + notice: FormControl; + groupId: FormControl; +} + +@Component({ + selector: 'ofs-consumable-create', + imports: [ + FormsModule, + NzInputModule, + NzButtonModule, + NzGridModule, + NzFormModule, + ReactiveFormsModule, + NzOptionComponent, + NzSelectComponent, + NzIconModule, + ], + templateUrl: './consumable-create.component.html', + styleUrl: './consumable-create.component.less' +}) +export class ConsumableCreateComponent implements OnDestroy { + form = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(1), Validators.maxLength(100)] + }), + groupId: new FormControl(null, { + validators: [Validators.min(1)] + }), + notice: new FormControl('', { + validators: [Validators.maxLength(2000)] + }), + }); + + + consumableGroups: Signal; + consumableGroupsIsLoading: Signal; + createLoading: Signal; + private destroy$ = new Subject(); + + constructor( + private readonly service: ConsumableCreateService, + private readonly inAppMessagingService: InAppMessageService, + ) { + this.createLoading = this.service.createLoading; + this.consumableGroups = this.service.consumableGroups; + this.consumableGroupsIsLoading = this.service.consumableGroupsIsLoading; + + this.service.createLoadingError + .pipe(takeUntil(this.destroy$)) + .subscribe((x) => this.inAppMessagingService.showError(x)); + this.service.createLoadingSuccess + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.inAppMessagingService.showSuccess('Änderungen gespeichert')); + } + + submit() { + this.service.create(this.form.getRawValue()); + } + + ngOnDestroy(): void { + this.destroy$.next(); + } + + onSearchGroup(search: string) { + this.service.onSearchGroup(search); + } +} diff --git a/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.spec.ts b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.spec.ts new file mode 100644 index 0000000..5b31b48 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ConsumableCreateService } from './consumable-create.service'; + +describe('ConsumableCreateService', () => { + let service: ConsumableCreateService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ConsumableCreateService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.ts b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.ts new file mode 100644 index 0000000..f6fa98c --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.ts @@ -0,0 +1,78 @@ +import {Inject, Injectable, signal} from '@angular/core'; +import {BehaviorSubject, debounceTime, filter, Subject} from 'rxjs'; +import {ConsumableService} from '@backend/api/consumable.service'; +import {Router} from '@angular/router'; +import {HttpErrorResponse} from '@angular/common/http'; +import {ConsumableCreateDto} from '@backend/model/consumableCreateDto'; +import {ConsumableGroupDto} from '@backend/model/consumableGroupDto'; +import {SEARCH_DEBOUNCE_TIME, SELECT_ITEMS_COUNT} from '../../../../app.configs'; +import {ConsumableGroupService} from '@backend/api/consumableGroup.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumableCreateService { + createLoading = signal(false); + createLoadingError = new Subject(); + createLoadingSuccess = new Subject(); + + consumableGroupsSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + consumableGroupsSearch = ''; + consumableGroups = signal([]); + consumableGroupsIsLoading = signal(false); + + constructor( + private readonly apiService: ConsumableService, + private readonly apiConsumableGroupsService: ConsumableGroupService, + private readonly router: Router, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, + ) { + this.consumableGroupsSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.consumableGroupsSearch = x.value; + this.loadMoreGroups(); + }); + } + + create(rawValue: ConsumableCreateDto) { + this.createLoading.set(true); + this.apiService.consumableControllerCreate({consumableCreateDto: rawValue}) + .subscribe({ + next: (entity) => { + this.createLoading.set(false); + this.createLoadingSuccess.next(); + this.router.navigate(['inventory', 'consumables', entity.id]); + }, + error: (err: HttpErrorResponse) => { + this.createLoading.set(false); + this.createLoadingError.next(err.status === 400 ? err.error.message : 'Fehler beim speichern.'); + }, + }); + } + + loadMoreGroups() { + this.consumableGroupsIsLoading.set(true); + this.apiConsumableGroupsService + .consumableGroupControllerGetAll({ + limit: this.selectCount, + searchTerm: this.consumableGroupsSearch, + }) + .subscribe({ + next: (deviceGroups) => { + this.consumableGroupsIsLoading.set(false); + this.consumableGroups.set(deviceGroups); + }, + error: () => { + this.consumableGroupsIsLoading.set(false); + this.consumableGroups.set([]); + } + }); + } + + onSearchGroup(value: string): void { + this.consumableGroupsSearch$.next({propagate: true, value}); + } +} diff --git a/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.html b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.html new file mode 100644 index 0000000..7a6a6b4 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.html @@ -0,0 +1,184 @@ +

    Verbrauchsmaterial bearbeiten

    + +

    Das Verbrauchsmaterial wurde nicht gefunden!

    +
    + + Verbrauchsmaterial wird geladen... + + + + +
    + + Name + + + + @if (control.errors?.['required']) { + Bitte einen Namen eingeben. + } + @if (control.errors?.['minlength']) { + Bitte mindestens 1 Zeichen eingeben. + } + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Verbrauchsmaterial-Gruppe + + + @if (consumableGroupsIsLoading()) { + + + Laden... + + } @else { + @for (item of consumableGroups(); track item) { + + } + } + + + + + + Weitere Informationen + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 2000 Zeichen eingeben. + } + + + + + + + + + + +
    +
    + + + + + + Ort + Anzahl + Ablaufdatum + Notiz + + + + + + {{ getLocationName(loc.location) }} + {{ loc.quantity }} + {{ loc.expirationDate | date }} + {{ loc.notice }} + + + + + + + + +
    +
    + + + +
    + + Standort/Fahrzeug + + + @if (locationsIsLoading()) { + + } @else { + @for (item of locations(); track item) { + + } + } + + + + + + Anzahl + + + + @if (control.errors?.['required']) { + Bitte eine Anzahl eingeben. + } + + + + + + Ablaufdatum + + + + @if (control.errors?.['required']) { + Bitte eine Anzahl eingeben. + } + + + + + + Weitere Informationen + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 2000 Zeichen eingeben. + } + + + +
    + + + + + +
    diff --git a/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.less b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.spec.ts b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.spec.ts new file mode 100644 index 0000000..4089a8b --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConsumableDetailComponent } from './consumable-detail.component'; + +describe('ConsumableDetailComponent', () => { + let component: ConsumableDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConsumableDetailComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ConsumableDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.ts b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.ts new file mode 100644 index 0000000..cd83a99 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.ts @@ -0,0 +1,203 @@ +import {Component, effect, OnDestroy, OnInit, Signal} from '@angular/core'; +import {Form, FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {Subject, takeUntil} from 'rxjs'; +import {ConsumableGroupDto} from '@backend/model/consumableGroupDto'; +import {ActivatedRoute} from '@angular/router'; +import {ConsumableDetailService} from './consumable-detail.service'; +import {DatePipe, NgForOf, NgIf} from '@angular/common'; +import {NzInputModule} from 'ng-zorro-antd/input'; +import {NzButtonModule} from 'ng-zorro-antd/button'; +import {NzFormModule} from 'ng-zorro-antd/form'; +import {NzSelectModule} from 'ng-zorro-antd/select'; + +import {NzPopconfirmModule} from 'ng-zorro-antd/popconfirm'; +import {NzCheckboxModule} from 'ng-zorro-antd/checkbox'; +import {NzInputNumberModule} from 'ng-zorro-antd/input-number'; +import {NzSpinModule} from 'ng-zorro-antd/spin'; +import {NzDatePickerModule} from 'ng-zorro-antd/date-picker'; +import {NzTabsModule} from 'ng-zorro-antd/tabs'; +import {NzTableModule} from 'ng-zorro-antd/table'; +import {ConsumableDto} from '@backend/model/consumableDto'; +import {LocationDto} from '@backend/model/locationDto'; +import {NzDrawerModule} from 'ng-zorro-antd/drawer'; +import {ConsumableLocationDto} from '@backend/model/consumableLocationDto'; + +interface ConsumableUpdateForm { + name: FormControl; + notice: FormControl; + groupId: FormControl; +} + +interface ConsumableLocationForm { + locationId: FormControl; + quantity: FormControl; + expirationDate: FormControl; + notice: FormControl; +} + +@Component({ + selector: 'ofs-consumable-detail', + imports: [ + NgIf, + ReactiveFormsModule, + NzButtonModule, + NzFormModule, + NzInputModule, + NzCheckboxModule, + NzPopconfirmModule, + NzSelectModule, + NzInputNumberModule, + NzSpinModule, + NzDatePickerModule, + NzTabsModule, + NgForOf, + NzTableModule, + NzDrawerModule, + DatePipe, + ], + templateUrl: './consumable-detail.component.html', + standalone: true, + styleUrl: './consumable-detail.component.less' +}) +export class ConsumableDetailComponent implements OnInit, OnDestroy { + notFound: Signal; + loading: Signal; + loadingError: Signal; + updateLoading: Signal; + deleteLoading: Signal; + + private destroy$ = new Subject(); + form = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(1), Validators.maxLength(100)] + }), + groupId: new FormControl(null, { + validators: [Validators.min(1)] + }), + notice: new FormControl('', { + validators: [Validators.maxLength(2000)] + }), + }); + + formLocation = new FormGroup({ + locationId: new FormControl(0, { + validators: [Validators.required], + nonNullable: true, + }), + quantity: new FormControl(1, { + validators: [Validators.required, Validators.min(1)], + nonNullable: true, + }), + expirationDate: new FormControl(null), + notice: new FormControl('', { + validators: [Validators.maxLength(2000)] + }), + }); + + consumableGroups: Signal; + consumableGroupsIsLoading: Signal; + locations: Signal; + locationsIsLoading: Signal; + entity: Signal; + locationRelationFormVisible: Signal; + locationRelationFormTitle: Signal; + + constructor( + private readonly activatedRoute: ActivatedRoute, + private readonly service: ConsumableDetailService, + ) { + this.entity = this.service.entity; + this.notFound = this.service.notFound; + this.loading = this.service.loading; + this.loadingError = this.service.loadingError; + this.deleteLoading = this.service.deleteLoading; + this.updateLoading = this.service.updateLoading; + + this.consumableGroups = this.service.consumableGroups; + this.consumableGroupsIsLoading = this.service.consumableGroupsIsLoading; + this.locations = this.service.locations; + this.locationsIsLoading = this.service.locationsIsLoading; + this.locationRelationFormVisible = this.service.locationRelationFormVisible; + this.locationRelationFormTitle = this.service.locationRelationFormTitle; + + effect(() => { + const entity = this.service.entity(); + if (entity) { + this.form.patchValue(entity as any); + } + }); + effect(() => { + this.service.resetLocationForm(); + this.formLocation.reset(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.activatedRoute.params + .pipe(takeUntil(this.destroy$)) + .subscribe(params => { + this.service.load(params['id']); + }); + } + + submit() { + this.service.update(this.form.getRawValue() as any); + } + + delete() { + this.service.delete(); + } + + onSearchGroup(search: string) { + this.service.onSearchGroup(search); + } + + getLocationName(location: LocationDto | null | undefined) { + return location?.name; // TODO maybe add parent location name if available + } + + deleteConsumableLocation(id: number) { + this.service.deleteConsumableLocation(id); + } + + locationRelationFormClose() { + this.service.locationRelationFormClose(); + this.formLocation.reset(); + } + + locationRelationFormOpenNew() { + this.service.locationRelationFormOpenNew(); + } + + locationRelationFormSave() { + if (this.formLocation.invalid) { + return; + } + + if (this.service.locationRelationId()) { + this.service.locationRelationUpdate(this.formLocation.getRawValue() as any); + } else { + this.service.locationRelationCreate(this.formLocation.getRawValue() as any); + } + } + + onSearchLocation(value: string) { + this.service.onSearchLocation(value); + } + + locationRelationFormEditOpen(value: ConsumableLocationDto) { + this.formLocation.patchValue({ + locationId: value.locationId, + quantity: value.quantity, + expirationDate: value.expirationDate ? new Date(value.expirationDate) : null, + notice: value.notice || null, + }); + this.service.locationRelationFormEditOpen(value.id); + } +} diff --git a/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.spec.ts b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.spec.ts new file mode 100644 index 0000000..1fd0650 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ConsumableDetailService } from './consumable-detail.service'; + +describe('ConsumableDetailService', () => { + let service: ConsumableDetailService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ConsumableDetailService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.ts b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.ts new file mode 100644 index 0000000..a820b11 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.ts @@ -0,0 +1,250 @@ +import {Inject, Injectable, Signal, signal} from '@angular/core'; +import {BehaviorSubject, debounceTime, filter, Subject} from 'rxjs'; +import {ConsumableGroupDto} from '@backend/model/consumableGroupDto'; +import {ConsumableDto} from '@backend/model/consumableDto'; +import {ConsumableService} from '@backend/api/consumable.service'; +import {Router} from '@angular/router'; +import {SEARCH_DEBOUNCE_TIME, SELECT_ITEMS_COUNT} from '../../../../app.configs'; +import {ConsumableGroupService} from '@backend/api/consumableGroup.service'; +import {HttpErrorResponse} from '@angular/common/http'; +import {ConsumableUpdateDto} from '@backend/model/consumableUpdateDto'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import {LocationDto} from '@backend/model/locationDto'; +import {LocationService} from '@backend/api/location.service'; +import {ConsumableLocationAddDto} from '@backend/model/consumableLocationAddDto'; +import {ConsumableLocationUpdateDto} from '@backend/model/consumableLocationUpdateDto'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumableDetailService { + id?: number; + entity = signal(null); + loading = signal(false); + loadingError = signal(false); + notFound = signal(false); + deleteLoading = signal(false); + updateLoading = signal(false); + locationRelationFormVisible = signal(false); + locationRelationFormTitle = signal(""); + resetLocationForm = signal(false); + + consumableGroupsSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + consumableGroupsSearch = ''; + consumableGroups = signal([]); + consumableGroupsIsLoading = signal(false); + + locationSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + locationSearch = ''; + locations = signal([]); + locationsIsLoading = signal(false); + + locationRelationId = signal(null); + + constructor( + private readonly apiService: ConsumableService, + private readonly apiConsumableGroupsService: ConsumableGroupService, + private readonly apiLocationsService: LocationService, + private readonly router: Router, + private readonly inAppMessagingService: InAppMessageService, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, + ) { + this.consumableGroupsSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.consumableGroupsSearch = x.value; + this.loadMoreGroups(); + }); + this.locationSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.locationSearch = x.value; + this.loadMoreLocations(); + }); + } + + + load(id: number) { + this.id = id; + + this.consumableGroups.set([]); + this.loading.set(true); + this.apiService.consumableControllerGetOne({id}) + .subscribe({ + next: (newEntity) => { + this.entity.set(newEntity); + this.loadingError.set(false); + this.loading.set(false); + if (newEntity.group) { + this.consumableGroups.set([newEntity.group]); + } + }, + error: (err: HttpErrorResponse) => { + if (err.status === 404) { + this.notFound.set(true); + } + this.entity.set(null); + this.inAppMessagingService.showError('Fehler beim laden des Geräts.'); + this.loading.set(false); + } + }); + } + + update(rawValue: ConsumableUpdateDto) { + const entity = this.entity(); + if (entity) { + this.updateLoading.set(true); + this.apiService.consumableControllerUpdate({id: entity.id, consumableUpdateDto: rawValue}) + .subscribe({ + next: (newEntity) => { + this.updateLoading.set(false); + this.entity.set(newEntity); + this.inAppMessagingService.showSuccess('Gerät gelöscht'); + }, + error: () => { + this.updateLoading.set(false); + this.inAppMessagingService.showError('Fehler beim speichern.'); + }, + }); + } + } + + delete() { + const entity = this.entity(); + if (entity) { + this.deleteLoading.set(true); + this.apiService.consumableControllerDelete({id: entity.id}) + .subscribe({ + next: () => { + this.deleteLoading.set(false); + this.inAppMessagingService.showInfo('Material wurde gelöscht.') + this.router.navigate(['inventory', 'consumables']); + }, + error: (err: HttpErrorResponse) => { + this.deleteLoading.set(false); + this.inAppMessagingService.showError(err.status === 400 ? err.error : 'Fehler beim löschen'); + }, + }); + } + } + + loadMoreGroups() { + this.consumableGroupsIsLoading.set(true); + this.apiConsumableGroupsService + .consumableGroupControllerGetAll({ + limit: this.selectCount, + searchTerm: this.consumableGroupsSearch, + }) + .subscribe({ + next: (deviceGroups) => { + this.consumableGroupsIsLoading.set(false); + this.consumableGroups.set(deviceGroups); + }, + error: () => { + this.consumableGroupsIsLoading.set(false); + this.consumableGroups.set([]); + } + }); + } + + loadMoreLocations() { + this.locationsIsLoading.set(true); + this.apiLocationsService + .locationControllerGetAll({ + limit: this.selectCount, + searchTerm: this.locationSearch + }) + .subscribe({ + next: (locations) => { + this.locationsIsLoading.set(false); + this.locations.set(locations); + }, + error: () => { + this.locationsIsLoading.set(false); + this.locations.set([]); + } + }); + } + + onSearchGroup(value: string): void { + this.consumableGroupsSearch$.next({propagate: true, value}); + } + + deleteConsumableLocation(id: number) { + this.apiService.consumableControllerRemoveLocation({ + id: this.id!, + relationId: id, + }).subscribe( + { + next: (newEntity) => { + this.entity.set(newEntity); + this.inAppMessagingService.showSuccess('Material wurde entfernt.'); + }, + error: (err: HttpErrorResponse) => { + this.inAppMessagingService.showError(err.status === 400 ? err.error : 'Fehler beim entfernen des Materials'); + } + } + ); + } + + locationRelationFormClose() { + this.locationRelationFormVisible.set(false); + } + + locationRelationFormOpenNew() { + this.locationRelationId.set(null); + this.locationSearch$.next({propagate: true, value: ''}); + this.locationRelationFormTitle.set("Material - Ort anlegen") + this.locationRelationFormVisible.set(true); + } + + locationRelationFormEditOpen(relationId: number) { + this.locationRelationId.set(relationId); + this.locationSearch$.next({propagate: true, value: ''}); + this.locationRelationFormTitle.set("Material - Ort bearbeiten") + this.locationRelationFormVisible.set(true); + } + + onSearchLocation(value: string): void { + this.locationSearch$.next({propagate: true, value}); + } + + locationRelationCreate(value: ConsumableLocationAddDto) { + this.apiService.consumableControllerAddLocation({id: this.id!, consumableLocationAddDto: value}) + .subscribe({ + next: (newEntity) => { + this.entity.set(newEntity); + this.locationRelationFormVisible.set(false); + this.resetLocationForm.set(!this.resetLocationForm()); + this.inAppMessagingService.showSuccess('Material wurde angelegt.'); + }, + error: (err: HttpErrorResponse) => { + this.inAppMessagingService.showError(err.status === 400 ? err.error : 'Fehler beim Anlegen des Materials'); + } + }); + } + + locationRelationUpdate(value: ConsumableLocationUpdateDto) { + if (!this.locationRelationId()) return; + + this.apiService.consumableControllerUpdateLocation({ + id: this.id!, + consumableLocationUpdateDto: value, + relationId: this.locationRelationId()!, + }) + .subscribe({ + next: (newEntity) => { + this.entity.set(newEntity); + this.locationRelationFormVisible.set(false); + this.resetLocationForm.set(!this.resetLocationForm()); + this.inAppMessagingService.showSuccess('Material wurde aktualisiert.'); + }, + error: (err: HttpErrorResponse) => { + this.inAppMessagingService.showError(err.status === 400 ? err.error : 'Fehler beim Aktualisieren des Materials'); + } + }); + } +} diff --git a/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.html b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.html new file mode 100644 index 0000000..ae39b4b --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.html @@ -0,0 +1,27 @@ + + + + Name + Gruppe + Bemerkung + + + + + + {{ entity.name }} + {{ entity.group?.name }} + {{ entity.notice }} + + Details + + + + diff --git a/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.less b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.spec.ts b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.spec.ts new file mode 100644 index 0000000..3addc3a --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConsumableListComponent } from './consumable-list.component'; + +describe('ConsumableListComponent', () => { + let component: ConsumableListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConsumableListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ConsumableListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.ts b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.ts new file mode 100644 index 0000000..e3d555b --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.ts @@ -0,0 +1,55 @@ +import {Component, effect, Signal} from '@angular/core'; +import {NzTableModule, NzTableQueryParams} from 'ng-zorro-antd/table'; +import {CommonModule} from '@angular/common'; +import {ConsumablesService} from '../consumables.service'; +import {HasRoleDirective} from '../../../../core/auth/has-role.directive'; +import {NzButtonComponent} from 'ng-zorro-antd/button'; +import {RouterLink} from '@angular/router'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import {ConsumableDto} from '@backend/model/consumableDto'; + +@Component({ + selector: 'ofs-consumable-list', + imports: [ + NzTableModule, + CommonModule, + HasRoleDirective, + NzButtonComponent, + RouterLink + ], + standalone: true, + templateUrl: './consumable-list.component.html', + styleUrl: './consumable-list.component.less' +}) +export class ConsumableListComponent { + + entities: Signal; + total: Signal; + entitiesLoading: Signal; + itemsPerPage: number; + page: number; + + constructor( + private readonly service: ConsumablesService, + private readonly inAppMessagingService: InAppMessageService, + ) { + this.entities = this.service.entities; + this.total = this.service.total; + this.entitiesLoading = this.service.entitiesLoading; + this.itemsPerPage = this.service.itemsPerPage; + this.page = this.service.page; + + effect(() => { + const error = this.service.entitiesLoadError(); + if (error) { + this.inAppMessagingService.showError('Fehler beim laden der Geräte.'); + } + }); + } + + onQueryParamsChange(params: NzTableQueryParams) { + const {pageSize, pageIndex, sort} = params; + const sortCol = sort.find(x => x.value); + this.service.updatePage(pageIndex, pageSize, sortCol?.key, sortCol?.value === 'ascend' ? 'ASC' : 'DESC'); + } +} diff --git a/frontend/src/app/pages/inventory/consumables/consumables.component.html b/frontend/src/app/pages/inventory/consumables/consumables.component.html new file mode 100644 index 0000000..a6f955c --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumables.component.html @@ -0,0 +1,17 @@ +

    Verbrauchsmaterial

    +
    +
    + +
    +
    + + + + + + +
    +
    + diff --git a/frontend/src/app/pages/inventory/consumables/consumables.component.less b/frontend/src/app/pages/inventory/consumables/consumables.component.less new file mode 100644 index 0000000..3637fd7 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumables.component.less @@ -0,0 +1 @@ +// Consumables component styles \ No newline at end of file diff --git a/frontend/src/app/pages/inventory/consumables/consumables.component.ts b/frontend/src/app/pages/inventory/consumables/consumables.component.ts new file mode 100644 index 0000000..330cd97 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumables.component.ts @@ -0,0 +1,42 @@ +import { Component, OnInit } from '@angular/core'; +import { ConsumablesService } from './consumables.service'; +import { ConsumableListComponent } from './consumable-list/consumable-list.component'; +import {HasRoleDirective} from '../../../core/auth/has-role.directive'; +import {NzButtonComponent} from 'ng-zorro-antd/button'; +import {NzColDirective, NzRowDirective} from 'ng-zorro-antd/grid'; +import {NzIconDirective} from 'ng-zorro-antd/icon'; +import {NzInputDirective, NzInputGroupComponent, NzInputGroupWhitSuffixOrPrefixDirective} from 'ng-zorro-antd/input'; +import {NzWaveDirective} from 'ng-zorro-antd/core/wave'; +import {RouterLink} from '@angular/router'; + +@Component({ + selector: 'ofs-consumables', + imports: [ + ConsumableListComponent, + HasRoleDirective, + NzButtonComponent, + NzColDirective, + NzIconDirective, + NzInputDirective, + NzInputGroupComponent, + NzInputGroupWhitSuffixOrPrefixDirective, + NzRowDirective, + NzWaveDirective, + RouterLink + ], + standalone: true, + templateUrl: './consumables.component.html', + styleUrls: ['./consumables.component.less'] +}) +export class ConsumablesComponent implements OnInit { + constructor(private service: ConsumablesService) {} + + search($event: Event) { + const target = $event.target as HTMLInputElement; + this.service.search(target.value); + } + + ngOnInit() { + this.service.init(); + } +} diff --git a/frontend/src/app/pages/inventory/consumables/consumables.service.ts b/frontend/src/app/pages/inventory/consumables/consumables.service.ts new file mode 100644 index 0000000..cc7586a --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumables.service.ts @@ -0,0 +1,78 @@ +import {Injectable, Inject, signal} from '@angular/core'; +import {BehaviorSubject, debounceTime, distinctUntilChanged, filter, Observable} from 'rxjs'; +import {ConsumableDto, ConsumableCreateDto, ConsumableUpdateDto, CountDto, ConsumableService} from '@backend/index'; +import {SEARCH_DEBOUNCE_TIME} from '../../../app.configs'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumablesService { + entities = signal([]); + page = 1; + itemsPerPage = 20; + total = signal(0); + entitiesLoading = signal(false); + entitiesLoadError = signal(false); + sortCol?: string; + sortDir?: string; + searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); + private searchTerm?: string; + + constructor( + private readonly apiService: ConsumableService, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + ) { + this.searchTerm$ + .pipe( + filter(x => x.propagate), + debounceTime(time), + distinctUntilChanged(), + ) + .subscribe(term => { + this.searchTerm = term.value; + this.load(); + }); + } + + load() { + this.entitiesLoading.set(true); + this.apiService.consumableControllerGetCount({searchTerm: this.searchTerm}) + .subscribe((count) => this.total.set(count.count)); + this.apiService.consumableControllerGetAll({ + limit: this.itemsPerPage, + offset: (this.page - 1) * this.itemsPerPage, + sortCol: this.sortCol, + sortDir: this.sortDir, + searchTerm: this.searchTerm + }) + .subscribe({ + next: (entities) => { + this.entities.set(entities); + this.entitiesLoading.set(false); + this.entitiesLoadError.set(false); + }, + error: () => { + this.entitiesLoading.set(false); + this.entitiesLoadError.set(true); + } + }); + } + + updatePage(page: number, itemsPerPage: number, sortCol?: string, sortDir?: string) { + this.page = page; + this.itemsPerPage = itemsPerPage; + this.sortCol = sortCol; + this.sortDir = this.sortCol ? sortDir : undefined; + this.load(); + } + + search(term: string) { + this.searchTerm$.next({propagate: true, value: term}); + this.page = 1; + } + + init() { + this.searchTerm = ''; + this.searchTerm$.next({propagate: false, value: ''}); + } +} diff --git a/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.component.html b/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.component.html index 8bf5c13..bf1cb6b 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.component.html +++ b/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.component.html @@ -1,4 +1,4 @@ -

    Geräte-Typ erstellen

    +

    Geräte-Gruppe erstellen

    diff --git a/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.component.ts b/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.component.ts index 8712259..cef4d57 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.component.ts +++ b/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.component.ts @@ -26,7 +26,7 @@ interface DeviceGroupDetailForm { templateUrl: './device-group-detail.component.html', styleUrl: './device-group-detail.component.less' }) -export class DeviceGroupDetailComponent implements OnInit, OnDestroy { +export class DeviceGroupDetailComponent implements OnInit, OnDestroy { notFound: Signal; loading: Signal; loadingError: Signal; diff --git a/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.service.ts b/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.service.ts index 912b548..d6609ee 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.service.ts +++ b/frontend/src/app/pages/inventory/device-groups/device-group-detail/device-group-detail.service.ts @@ -23,7 +23,7 @@ export class DeviceGroupDetailService { deleteLoadingSuccess = new Subject(); constructor( - private readonly locationService: DeviceGroupService, + private readonly service: DeviceGroupService, private readonly router: Router, ) { } @@ -31,7 +31,7 @@ export class DeviceGroupDetailService { load(id: number) { this.id = id; this.loading.set(true); - this.locationService.deviceGroupControllerGetOne({id}) + this.service.deviceGroupControllerGetOne({id}) .subscribe({ next: (newEntity) => { this.entity.set(newEntity); @@ -53,7 +53,7 @@ export class DeviceGroupDetailService { const entity = this.entity(); if (entity) { this.updateLoading.set(true); - this.locationService.deviceGroupControllerUpdate({id: entity.id, deviceGroupUpdateDto: rawValue}) + this.service.deviceGroupControllerUpdate({id: entity.id, deviceGroupUpdateDto: rawValue}) .subscribe({ next: (newEntity) => { this.updateLoading.set(false); @@ -72,7 +72,7 @@ export class DeviceGroupDetailService { const entity = this.entity(); if (entity) { this.deleteLoading.set(true); - this.locationService.deviceGroupControllerDelete({id: entity.id}) + this.service.deviceGroupControllerDelete({id: entity.id}) .subscribe({ next: () => { this.deleteLoading.set(false); diff --git a/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.html b/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.html index 9df7f9d..0f41a51 100644 --- a/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.html +++ b/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.html @@ -1,232 +1,273 @@

    Gerät bearbeiten

    -

    Der Gerät wurde nicht gefunden!

    +

    Das Gerät wurde nicht gefunden!

    Gerät wird geladen... - - - Name - - - - @if (control.errors?.['required']) { - Bitte einen Namen eingeben. - } - @if (control.errors?.['minlength']) { - Bitte mindestens 1 Zeichen eingeben. - } - @if (control.errors?.['maxlength']) { - Bitte maximal 100 Zeichen eingeben. - } - - - - - - Zustand - - - @for (item of states; track item) { - - } - - - @if (control.errors?.['required']) { - Bitte einen Zustand eingeben. - } - - - - - - Standort/Fahrzeug - - - @if (locationsIsLoading()) { - - } @else { - @for (item of locations(); track item) { - - } - } - - - - - - Geräte-Typ - - - @if (deviceTypesIsLoading()) { - - } @else { - @for (item of deviceTypes(); track item) { - - } - } - - - - - - Geräte-Gruppe - - - @if (deviceGroupsIsLoading()) { - - } @else { - @for (item of deviceGroups(); track item) { - - } - } - - - - - - Hersteller - - - - @if (control.errors?.['maxlength']) { - Bitte maximal 100 Zeichen eingeben. - } - - - - - - Händler - - - - @if (control.errors?.['maxlength']) { - Bitte maximal 100 Zeichen eingeben. - } - - - - - - Hersteller-Seriennummer - - - - @if (control.errors?.['maxlength']) { - Bitte maximal 100 Zeichen eingeben. - } - - - - - - Seriennummer - - - - @if (control.errors?.['maxlength']) { - Bitte maximal 100 Zeichen eingeben. - } - - - - - - Barcode 1 - - - - @if (control.errors?.['maxlength']) { - Bitte maximal 100 Zeichen eingeben. - } - - - - - - Barcode 2 - - - - @if (control.errors?.['maxlength']) { - Bitte maximal 100 Zeichen eingeben. - } - - - - - - Herstellungsdatum - - - - - - - Inbetriebnahme - - - - - - - Außerbetriebnahme (Hersteller) - - - - - - - Außerbetriebnahme - - - - - - - Weitere Informationen - + + + + + Name + + + + @if (control.errors?.['required']) { + Bitte einen Namen eingeben. + } + @if (control.errors?.['minlength']) { + Bitte mindestens 1 Zeichen eingeben. + } + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Zustand + + + @for (item of states; track item) { + + } + + + @if (control.errors?.['required']) { + Bitte einen Zustand eingeben. + } + + + + + + Standort/Fahrzeug + + + @if (locationsIsLoading()) { + + } @else { + @for (item of locations(); track item) { + + } + } + + + + + + Geräte-Typ + + + @if (deviceTypesIsLoading()) { + + } @else { + @for (item of deviceTypes(); track item) { + + } + } + + + + + + Geräte-Gruppe + + + @if (deviceGroupsIsLoading()) { + + } @else { + @for (item of deviceGroups(); track item) { + + } + } + + + + + + Hersteller + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Händler + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Hersteller-Seriennummer + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Seriennummer + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Barcode 1 + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Barcode 2 + + + + @if (control.errors?.['maxlength']) { + Bitte maximal 100 Zeichen eingeben. + } + + + + + + Herstellungsdatum + + + + + + + Inbetriebnahme + + + + + + + Außerbetriebnahme (Hersteller) + + + + + + + Außerbetriebnahme + + + + + + + Weitere Informationen + - - @if (control.errors?.['maxlength']) { - Bitte maximal 2000 Zeichen eingeben. - } - - - - - - - - - - - + + @if (control.errors?.['maxlength']) { + Bitte maximal 2000 Zeichen eingeben. + } + + + + + + + + + + + + + +
    +
    + + + + + +
      +
    • + +
    • +
    +
    +
    + + example + +
    +
    +
    + + +
    +
    +
    diff --git a/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.ts b/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.ts index d2b41f0..27ba3dd 100644 --- a/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.ts +++ b/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.ts @@ -1,5 +1,5 @@ import {Component, effect, OnDestroy, OnInit, Signal} from '@angular/core'; -import {Subject, takeUntil} from 'rxjs'; +import {interval, tap, mergeMap, Subject, Subscription, takeUntil, takeWhile} from 'rxjs'; import {ActivatedRoute} from '@angular/router'; import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; import {DeviceDetailService} from './device-detail.service'; @@ -12,7 +12,7 @@ import { } from 'ng-zorro-antd/form'; import {NzSelectModule} from 'ng-zorro-antd/select'; import {NzPopconfirmModule} from 'ng-zorro-antd/popconfirm'; -import {NgIf} from '@angular/common'; +import {NgForOf, NgIf} from '@angular/common'; import {NzCheckboxModule} from 'ng-zorro-antd/checkbox'; import {NzInputNumberModule} from 'ng-zorro-antd/input-number'; import {NzSpinModule} from 'ng-zorro-antd/spin'; @@ -20,6 +20,16 @@ import {DeviceTypeDto} from '@backend/model/deviceTypeDto'; import {NzDatePickerModule} from 'ng-zorro-antd/date-picker'; import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; import {LocationDto} from '@backend/model/locationDto'; +import {NzTabsModule} from 'ng-zorro-antd/tabs'; +import {NzUploadChangeParam, NzUploadFile, NzUploadXHRArgs} from 'ng-zorro-antd/upload'; +import {ImageUploadAreaComponent} from '../../../../shared/image-upload-area/image-upload-area.component'; +import {DeviceDto} from '@backend/model/deviceDto'; +import {NzCardModule} from 'ng-zorro-antd/card'; +import {NzFlexModule} from 'ng-zorro-antd/flex'; +import {NzNotificationService} from 'ng-zorro-antd/notification'; +import {NzIconModule} from 'ng-zorro-antd/icon'; +import {NzDropDownModule} from 'ng-zorro-antd/dropdown'; +import {imageSizes} from '../../../../shared/config'; interface DeviceDetailForm { name: FormControl; @@ -54,16 +64,25 @@ interface DeviceDetailForm { NzInputNumberModule, NzSpinModule, NzDatePickerModule, + NzTabsModule, + NzCardModule, + NzFlexModule, + ImageUploadAreaComponent, + NzIconModule, + NzDropDownModule, + NgForOf, ], templateUrl: './device-detail.component.html', styleUrl: './device-detail.component.less' }) export class DeviceDetailComponent implements OnInit, OnDestroy { + imageSizes = imageSizes; notFound: Signal; loading: Signal; loadingError: Signal; updateLoading: Signal; deleteLoading: Signal; + entity: Signal; private destroy$ = new Subject(); @@ -120,6 +139,7 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { private readonly activatedRoute: ActivatedRoute, private readonly service: DeviceDetailService, private readonly inAppMessagingService: InAppMessageService, + private readonly notification: NzNotificationService, ) { this.notFound = this.service.notFound; this.loading = this.service.loading; @@ -132,6 +152,7 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { this.deviceGroupsIsLoading = this.service.deviceGroupsIsLoading; this.locations = this.service.locations; this.locationsIsLoading = this.service.locationsIsLoading; + this.entity = this.service.entity; effect(() => { const entity = this.service.entity(); @@ -183,8 +204,8 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { this.activatedRoute.params .pipe(takeUntil(this.destroy$)) .subscribe(params => { - this.service.load(params['id']); - }); + this.service.load(params['id']); + }); } submit() { @@ -206,4 +227,48 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { onSearchGroup(search: string) { this.service.onSearchGroup(search); } + + imgList: NzUploadFile[] = []; + + uploadImage(item: NzUploadXHRArgs): Subscription { + const comp = (item.data as DeviceDetailComponent); + return comp.service.uploadImage('test', item); + } + + imageChanged(event: NzUploadChangeParam) { + // Upload event handling, wenn sich etwas ändert + if (event.type === "success") { + let i = 0; + const pollInterval = 500; + interval(pollInterval) + .pipe( + mergeMap(() => this.service.isImageUploaded(event.file.uid)), + tap(() => i++), + takeWhile(x => !x && i < 30 / (pollInterval / 1000)), + ) + .subscribe({ + complete: () => { + this.notification.create("success", "Hochgeladen", `Datei wurde erfolgreich hochgeladen!`); + } + }); + } else if (event.type === 'error') { + const index = this.imgList.indexOf(event.file); + if (index !== -1) { + this.imgList.splice(index, 1); + } + this.notification.create("error", "Fehler", "Fehler beim hochladen"); + } + } + + downloadImage(imageId: string, size: string) { + this.service.getDownloadUrl(imageId, size); + } + + deleteImage(id: string) { + this.service.deleteImage(id); + } + + updateDefaultImage(id: string) { + this.service.updateDefaultImage(id); + } } diff --git a/frontend/src/app/pages/inventory/devices/device-detail/device-detail.service.ts b/frontend/src/app/pages/inventory/devices/device-detail/device-detail.service.ts index bdb152c..acb57e1 100644 --- a/frontend/src/app/pages/inventory/devices/device-detail/device-detail.service.ts +++ b/frontend/src/app/pages/inventory/devices/device-detail/device-detail.service.ts @@ -2,8 +2,13 @@ import {Inject, Injectable, signal} from '@angular/core'; import {DeviceService} from '@backend/api/device.service'; import {DeviceTypeService} from '@backend/api/deviceType.service'; import {Router} from '@angular/router'; -import {HttpErrorResponse} from '@angular/common/http'; -import {BehaviorSubject, debounceTime, filter, Subject} from 'rxjs'; +import { + HttpClient, + HttpErrorResponse, + HttpEventType, + HttpResponse +} from '@angular/common/http'; +import {BehaviorSubject, debounceTime, filter, map, mergeMap, Observable, Subject, tap} from 'rxjs'; import {DeviceDto} from '@backend/model/deviceDto'; import {DeviceTypeDto} from '@backend/model/deviceTypeDto'; import {DeviceUpdateDto} from '@backend/model/deviceUpdateDto'; @@ -12,6 +17,7 @@ import {DeviceGroupService} from '@backend/api/deviceGroup.service'; import {LocationDto} from '@backend/model/locationDto'; import {LocationService} from '@backend/api/location.service'; import {SEARCH_DEBOUNCE_TIME, SELECT_ITEMS_COUNT} from '../../../../app.configs'; +import {NzUploadXHRArgs} from 'ng-zorro-antd/upload'; @Injectable({ providedIn: 'root' @@ -43,6 +49,7 @@ export class DeviceDetailService { locationSearch = ''; locations = signal([]); locationsIsLoading = signal(false); + uploadIds: { imageId: string, fileId: string }[] = []; constructor( private readonly apiService: DeviceService, @@ -50,6 +57,7 @@ export class DeviceDetailService { private readonly apiDeviceGroupsService: DeviceGroupService, private readonly apiLocationsService: LocationService, private readonly router: Router, + private readonly http: HttpClient, @Inject(SEARCH_DEBOUNCE_TIME) time: number, @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, ) { @@ -129,6 +137,25 @@ export class DeviceDetailService { } } + updateDefaultImage(defaultImageId: string) { + const entity = this.entity(); + if (entity) { + this.updateLoading.set(true); + this.apiService.deviceControllerUpdate({id: entity.id, deviceUpdateDto: {...entity, defaultImageId}}) + .subscribe({ + next: (newEntity) => { + this.updateLoading.set(false); + this.entity.set(newEntity); + this.updateLoadingSuccess.next(); + }, + error: () => { + this.updateLoading.set(false); + this.updateLoadingError.next(); + }, + }); + } + } + delete() { const entity = this.entity(); if (entity) { @@ -216,4 +243,90 @@ export class DeviceDetailService { onSearchGroup(value: string): void { this.deviceGroupsSearch$.next({propagate: true, value}); } + + uploadImage(fileName: string, data: NzUploadXHRArgs) { + return this.apiService.deviceControllerGetImageUploadUrl({ + id: this.id!, + contentType: data.file.type ?? '', + }).pipe( + mergeMap(uploadData => { + this.uploadIds.push({imageId: uploadData.id, fileId: data.file.uid}); + const formData = new FormData(); + Object.entries(uploadData.formData).forEach(([k, v]) => { + formData.append(k, v as string); + }); + formData.append('file', data.file as never, fileName); + return this.http.post(uploadData.postURL, formData, {reportProgress: true, observe: 'events'}); + }), + ) + .subscribe({ + next: (event) => { + if (event && event.type === HttpEventType.UploadProgress && data.onProgress) { + if (event.total && event.total > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (event as any).percent = event.loaded / event.total * 100; + } + // To process the upload progress bar, you must specify the `percent` attribute to indicate progress. + data.onProgress(event, data.file); + } else if (event instanceof HttpResponse && data.onSuccess) { + data.file['filename'] = fileName; + data.onSuccess(event.body, data.file, event); + } + }, error: (err) => { + if (data.onError) { + data.onError(err, data.file); + } + } + }); + } + + isImageUploaded(uid: string): Observable { + return this.apiService + .deviceControllerGetOne({id: this.id!}) + .pipe( + map((data) => { + const response = data.images!.some((x) => { + return this.uploadIds.some((y) => { + return y.imageId === x.id && y.fileId === uid; + }); + }) ?? false; + if (response) { + // Remove the uploadId if the image is found + const index = this.uploadIds.findIndex(y => y.fileId === uid); + if (index !== -1) { + this.uploadIds.splice(index, 1); + } + this.entity.set({...this.entity()!, images: data.images}); + } + + return response; + }), + ); + } + + getDownloadUrl(imageId: string, size: string) { + this.apiService + .deviceControllerDownloadImage({id: this.id!, imageId, size}) + .pipe(map(x => x.url)) + .subscribe((url) => window.open(url, '_blank')); + } + + deleteImage(id: string) { + this.apiService + .deviceControllerDeleteImage({id: this.id!, imageId: id}) + .subscribe({ + next: () => { + const entity = this.entity(); + if (entity) { + this.entity.set({ + ...entity, + images: entity.images?.filter(x => x.id !== id) ?? [], + }); + } + }, + error: (err: HttpErrorResponse) => { + console.error('Fehler beim Löschen des Bildes:', err); + }, + }); + } } diff --git a/frontend/src/app/pages/inventory/inventory.routes.ts b/frontend/src/app/pages/inventory/inventory.routes.ts index 7006193..1cd5a0c 100644 --- a/frontend/src/app/pages/inventory/inventory.routes.ts +++ b/frontend/src/app/pages/inventory/inventory.routes.ts @@ -9,6 +9,16 @@ import {DeviceGroupDetailComponent} from './device-groups/device-group-detail/de import { DevicesComponent } from './devices/devices.component'; import {DeviceCreateComponent} from './devices/device-create/device-create.component'; import {DeviceDetailComponent} from './devices/device-detail/device-detail.component'; +import { ConsumableGroupsComponent } from './consumable-groups/consumable-groups.component'; +import { ConsumablesComponent } from './consumables/consumables.component'; +import { + ConsumableGroupCreateComponent +} from './consumable-groups/consumable-group-create/consumable-group-create.component'; +import { + ConsumableGroupDetailComponent +} from './consumable-groups/consumable-group-detail/consumable-group-detail.component'; +import {ConsumableCreateComponent} from './consumables/consumable-create/consumable-create.component'; +import {ConsumableDetailComponent} from './consumables/consumable-detail/consumable-detail.component'; export const ROUTES: Route[] = [ { @@ -56,4 +66,34 @@ export const ROUTES: Route[] = [ component: DeviceGroupDetailComponent, canActivate: [roleGuard(['device-group.manage'])], }, + { + path: 'consumable-groups', + component: ConsumableGroupsComponent, + canActivate: [roleGuard(['consumable-group.view'])], + }, + { + path: 'consumable-groups/create', + component: ConsumableGroupCreateComponent, + canActivate: [roleGuard(['consumable-group.manage'])], + }, + { + path: 'consumable-groups/:id', + component: ConsumableGroupDetailComponent, + canActivate: [roleGuard(['consumable-group.manage'])], + }, + { + path: 'consumables', + component: ConsumablesComponent, + canActivate: [roleGuard(['consumable.view'])], + }, + { + path: 'consumables/create', + component: ConsumableCreateComponent, + canActivate: [roleGuard(['consumable.manage'])], + }, + { + path: 'consumables/:id', + component: ConsumableDetailComponent, + canActivate: [roleGuard(['consumable.manage'])], + }, ]; diff --git a/frontend/src/app/shared/config.ts b/frontend/src/app/shared/config.ts new file mode 100644 index 0000000..c91060e --- /dev/null +++ b/frontend/src/app/shared/config.ts @@ -0,0 +1,9 @@ +export const imageSizes = [ + { name: 'Original', size: '' }, + { name: 'WebP (4000px)', size: 'webp-4000' }, + { name: 'WebP (2000px)', size: 'webp-2000' }, + { name: 'WebP (1600px)', size: 'webp-1600' }, + { name: 'WebP (1200px)', size: 'webp-1200' }, + { name: 'WebP (480px)', size: 'webp-480' }, + { name: 'WebP-Blured (600px)', size: 'webp-600-blur' }, +]; diff --git a/frontend/src/app/shared/image-upload-area/image-upload-area.component.html b/frontend/src/app/shared/image-upload-area/image-upload-area.component.html new file mode 100644 index 0000000..179602f --- /dev/null +++ b/frontend/src/app/shared/image-upload-area/image-upload-area.component.html @@ -0,0 +1,16 @@ + +

    + +

    +

    Klicken oder ein Bild hier ablegen zum hochladen

    +

    Es werden ein oder mehrere Bilder unterstützt.

    +
    diff --git a/frontend/src/app/shared/image-upload-area/image-upload-area.component.less b/frontend/src/app/shared/image-upload-area/image-upload-area.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/shared/image-upload-area/image-upload-area.component.spec.ts b/frontend/src/app/shared/image-upload-area/image-upload-area.component.spec.ts new file mode 100644 index 0000000..f51fb67 --- /dev/null +++ b/frontend/src/app/shared/image-upload-area/image-upload-area.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ImageUploadAreaComponent } from './image-upload-area.component'; + +describe('ImageUploadAreaComponent', () => { + let component: ImageUploadAreaComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ImageUploadAreaComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ImageUploadAreaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/image-upload-area/image-upload-area.component.ts b/frontend/src/app/shared/image-upload-area/image-upload-area.component.ts new file mode 100644 index 0000000..8ba3dd7 --- /dev/null +++ b/frontend/src/app/shared/image-upload-area/image-upload-area.component.ts @@ -0,0 +1,27 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {NzUploadChangeParam, NzUploadFile, NzUploadModule, NzUploadXHRArgs} from 'ng-zorro-antd/upload'; +import {Observable, Subscription} from 'rxjs'; +import {NzIconModule} from 'ng-zorro-antd/icon'; + +@Component({ + selector: 'ofs-image-upload-area', + standalone: true, + imports: [ + NzUploadModule, + NzIconModule, + ], + templateUrl: './image-upload-area.component.html', + styleUrl: './image-upload-area.component.less' +}) +export class ImageUploadAreaComponent { + @Output() + readonly nzFileListChange = new EventEmitter(); + @Input() + nzFileList: NzUploadFile[] = []; + @Input() + nzCustomRequest?: (item: NzUploadXHRArgs) => Subscription; + @Input() + nzData?: {} | ((file: NzUploadFile) => {} | Observable<{}>); + @Output() + readonly nzChange = new EventEmitter(); +} diff --git a/frontend/src/app/shared/services/consumable-group-api.service.ts b/frontend/src/app/shared/services/consumable-group-api.service.ts new file mode 100644 index 0000000..e5b7db5 --- /dev/null +++ b/frontend/src/app/shared/services/consumable-group-api.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + ConsumableGroupDto, + ConsumableGroupCreateDto, + ConsumableGroupUpdateDto, + CountDto +} from '../models/consumable.models'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumableGroupApiService { + private readonly basePath = '/api/consumable-group'; + + constructor(private http: HttpClient) {} + + getCount(searchTerm?: string): Observable { + let params = new HttpParams(); + if (searchTerm) { + params = params.set('searchTerm', searchTerm); + } + return this.http.get(`${this.basePath}/count`, { params }); + } + + getAll(options?: { + limit?: number; + offset?: number; + sortCol?: string; + sortDir?: string; + searchTerm?: string; + }): Observable { + let params = new HttpParams(); + if (options?.limit) { + params = params.set('limit', options.limit.toString()); + } + if (options?.offset) { + params = params.set('offset', options.offset.toString()); + } + if (options?.sortCol) { + params = params.set('sortCol', options.sortCol); + } + if (options?.sortDir) { + params = params.set('sortDir', options.sortDir); + } + if (options?.searchTerm) { + params = params.set('searchTerm', options.searchTerm); + } + return this.http.get(this.basePath, { params }); + } + + getOne(id: number): Observable { + return this.http.get(`${this.basePath}/${id}`); + } + + create(dto: ConsumableGroupCreateDto): Observable { + return this.http.post(this.basePath, dto); + } + + update(id: number, dto: ConsumableGroupUpdateDto): Observable { + return this.http.put(`${this.basePath}/${id}`, dto); + } + + delete(id: number): Observable { + return this.http.delete(`${this.basePath}/${id}`); + } +} \ No newline at end of file diff --git a/frontend/src/styles.less b/frontend/src/styles.less index 4cf91f4..c4bb186 100644 --- a/frontend/src/styles.less +++ b/frontend/src/styles.less @@ -31,3 +31,8 @@ nz-date-picker { width: 100%; } + +.highlight { + border: 1px solid #52c41a; /* Success Border */ + background-color: #f6ffed; /* Hintergrundfarbe ähnlich nzAlert success */ +} diff --git a/openapi/backend.yml b/openapi/backend.yml index 138e4ed..f0ca06d 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -1048,6 +1048,565 @@ paths: summary: '' tags: - Device + /api/device/{id}/image-upload: + get: + description: Gibt die URL zum Hochladen eines Gerätebildes zurück + operationId: DeviceController_getImageUploadUrl + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: contentType + required: true + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UploadUrlDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Device + /api/device/{id}/image/{imageId}: + get: + description: Gibt die Url zum herunterladen des Gerätebild zurück. + operationId: DeviceController_downloadImage + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: imageId + required: true + in: path + schema: + type: string + - name: size + required: true + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/DownloadUrlDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Device + delete: + description: Löscht ein Bild + operationId: DeviceController_deleteImage + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: imageId + required: true + in: path + schema: + type: string + responses: + '204': + description: '' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Device + /api/consumable-group/count: + get: + description: Gibt die Anzahl aller Verbrauchsgüter-Gruppen zurück + operationId: ConsumableGroupController_getCount + parameters: + - name: searchTerm + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/CountDto' + '401': + description: '' + security: + - bearer: [] + summary: '' + tags: + - ConsumableGroup + /api/consumable-group: + get: + description: Gibt alle Verbrauchsgüter-Gruppen zurück + operationId: ConsumableGroupController_getAll + parameters: + - name: limit + required: false + in: query + schema: + type: number + - name: offset + required: false + in: query + schema: + type: number + - name: sortCol + required: false + in: query + schema: + type: string + - name: sortDir + required: false + in: query + schema: + type: string + enum: + - ASC + - DESC + - name: searchTerm + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ConsumableGroupDto' + '401': + description: '' + security: + - bearer: [] + summary: '' + tags: + - ConsumableGroup + post: + description: Erstellt eine Verbrauchsgüter-Gruppe + operationId: ConsumableGroupController_create + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableGroupCreateDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableGroupDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - ConsumableGroup + /api/consumable-group/{id}: + get: + description: Gibt eine Verbrauchsgüter-Gruppe zurück + operationId: ConsumableGroupController_getOne + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableGroupDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - ConsumableGroup + put: + description: Aktualisiert eine Verbrauchsgüter-Gruppe + operationId: ConsumableGroupController_update + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableGroupUpdateDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableGroupDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - ConsumableGroup + delete: + description: Löscht eine Verbrauchsgüter-Gruppe + operationId: ConsumableGroupController_delete + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '204': + description: '' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - ConsumableGroup + /api/consumable/count: + get: + description: Gibt die Anzahl aller Verbrauchsgüter zurück + operationId: ConsumableController_getCount + parameters: + - name: searchTerm + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/CountDto' + '401': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + /api/consumable: + get: + description: Gibt alle Verbrauchsgüter zurück + operationId: ConsumableController_getAll + parameters: + - name: limit + required: false + in: query + schema: + type: number + - name: offset + required: false + in: query + schema: + type: number + - name: groupId + required: false + in: query + schema: + nullable: true + type: number + - name: locationIds + required: false + in: query + schema: + nullable: true + type: array + items: + type: number + - name: sortCol + required: false + in: query + schema: + type: string + - name: sortDir + required: false + in: query + schema: + type: string + - name: searchTerm + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ConsumableDto' + '401': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + post: + description: Erstellt ein Verbrauchsgut + operationId: ConsumableController_create + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableCreateDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + /api/consumable/{id}: + get: + description: Gibt ein Verbrauchsgut zurück + operationId: ConsumableController_getOne + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + put: + description: Aktualisiert ein Verbrauchsgut + operationId: ConsumableController_update + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableUpdateDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + delete: + description: Löscht ein Verbrauchsgut + operationId: ConsumableController_delete + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '204': + description: '' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + /api/consumable/{id}/locations: + post: + description: Fügt einem Verbrauchsgut einen Standort hinzu + operationId: ConsumableController_addLocation + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableLocationAddDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + /api/consumable/{id}/locations/{relationId}: + delete: + description: Entfernt einen Standort von einem Verbrauchsgut + operationId: ConsumableController_removeLocation + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: relationId + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable + put: + description: >- + Aktualisiert die Relation zwischen einem Verbrauchsgut und einen + Standort. + operationId: ConsumableController_updateLocation + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: relationId + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableLocationUpdateDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Consumable /api/location/count: get: description: Gibt die Anzahl aller Standorte zurück @@ -1517,6 +2076,16 @@ components: - id - name - type + DeviceImageDto: + type: object + properties: + id: + type: string + previewUrl: + type: string + required: + - id + - previewUrl DeviceDto: type: object properties: @@ -1585,6 +2154,18 @@ components: nullable: true allOf: - $ref: '#/components/schemas/LocationDto' + images: + nullable: true + type: array + items: + $ref: '#/components/schemas/DeviceImageDto' + defaultImageId: + type: string + nullable: true + defaultImage: + nullable: true + allOf: + - $ref: '#/components/schemas/DeviceImageDto' required: - id - state @@ -1639,17 +2220,9 @@ components: groupId: type: number nullable: true - group: - nullable: true - allOf: - - $ref: '#/components/schemas/DeviceTypeDto' locationId: type: number nullable: true - location: - nullable: true - allOf: - - $ref: '#/components/schemas/LocationDto' required: - state DeviceUpdateDto: @@ -1703,19 +2276,207 @@ components: groupId: type: number nullable: true + locationId: + type: number + nullable: true + defaultImageId: + type: string + nullable: true + required: + - state + FormData: + type: object + properties: + bucket: + type: string + key: + type: string + Content-Type: + type: string + x-amz-date: + type: string + x-amz-algorithm: + type: string + x-amz-credential: + type: string + x-amz-signature: + type: string + policy: + type: string + required: + - bucket + - key + - Content-Type + - x-amz-date + - x-amz-algorithm + - x-amz-credential + - x-amz-signature + - policy + UploadUrlDto: + type: object + properties: + postURL: + type: string + formData: + $ref: '#/components/schemas/FormData' + id: + type: string + required: + - postURL + - formData + - id + DownloadUrlDto: + type: object + properties: + url: + type: string + required: + - url + ConsumableGroupDto: + type: object + properties: + id: + type: number + name: + type: string + notice: + type: string + nullable: true + required: + - id + - name + ConsumableGroupCreateDto: + type: object + properties: + name: + type: string + notice: + type: string + nullable: true + required: + - name + ConsumableGroupUpdateDto: + type: object + properties: + name: + type: string + notice: + type: string + nullable: true + ConsumableLocationDto: + type: object + properties: + id: + type: number + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + format: date-time + type: string + nullable: true + locationId: + type: number + nullable: false + location: + $ref: '#/components/schemas/LocationDto' + required: + - id + - quantity + - locationId + - location + ConsumableDto: + type: object + properties: + id: + type: number + name: + type: string + nullable: true + notice: + type: string + nullable: true + groupId: + type: number + nullable: true group: nullable: true allOf: - - $ref: '#/components/schemas/DeviceTypeDto' + - $ref: '#/components/schemas/ConsumableGroupDto' + consumableLocationIds: + nullable: true + type: array + items: + type: string + consumableLocations: + nullable: true + type: array + items: + $ref: '#/components/schemas/ConsumableLocationDto' + required: + - id + ConsumableCreateDto: + type: object + properties: + name: + type: string + nullable: true + notice: + type: string + nullable: true + groupId: + type: number + nullable: true + ConsumableUpdateDto: + type: object + properties: + name: + type: string + nullable: true + notice: + type: string + nullable: true + groupId: + type: number + nullable: true + ConsumableLocationAddDto: + type: object + properties: + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + format: date-time + type: string + nullable: true locationId: type: number + nullable: false + required: + - quantity + - locationId + ConsumableLocationUpdateDto: + type: object + properties: + notice: + type: string nullable: true - location: + quantity: + type: number + expirationDate: + format: date-time + type: string nullable: true - allOf: - - $ref: '#/components/schemas/LocationDto' + locationId: + type: number + nullable: false required: - - state + - quantity + - locationId LocationCreateDto: type: object properties: