From 835741e9e2375f6aa6cdd56d2fd8d979973b9c0a Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Mon, 26 May 2025 17:05:15 +0200 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=94=8E=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/base/location/location-db.service.ts | 24 ++++++++-- .../src/base/location/location.controller.ts | 7 ++- backend/src/base/location/location.service.ts | 6 ++- .../device-group-db.service.spec.ts | 5 +- .../device-group/device-group-db.service.ts | 24 ++++++++-- .../device-group/device-group.controller.ts | 7 ++- .../device-group/device-group.service.ts | 6 ++- .../device-type-db.service.spec.ts | 5 +- .../device-type/device-type-db.service.ts | 24 ++++++++-- .../device-type/device-type.controller.ts | 7 ++- .../device-type/device-type.service.ts | 6 ++- .../src/inventory/device/device-db.service.ts | 24 ++++++++-- .../src/inventory/device/device.controller.ts | 7 ++- .../src/inventory/device/device.service.ts | 6 ++- backend/src/shared/dto/search.dto.ts | 9 ++++ frontend/src/app/app.config.ts | 2 + frontend/src/app/app.configs.ts | 12 +++++ .../base/locations/locations.component.html | 14 +++++- .../base/locations/locations.component.ts | 22 ++++++++- .../pages/base/locations/locations.service.ts | 36 ++++++++++++-- .../device-groups.component.html | 16 ++++++- .../device-groups/device-groups.component.ts | 27 +++++++++-- .../device-groups/device-groups.service.ts | 34 +++++++++++-- .../device-types/device-types.component.html | 14 +++++- .../device-types/device-types.component.ts | 27 +++++++++-- .../device-types/device-types.service.ts | 34 +++++++++++-- .../inventory/devices/devices.component.html | 14 +++++- .../inventory/devices/devices.component.ts | 25 ++++++++-- .../inventory/devices/devices.service.ts | 35 ++++++++++++-- openapi/backend.yml | 48 +++++++++++++++++-- 30 files changed, 458 insertions(+), 69 deletions(-) create mode 100644 backend/src/shared/dto/search.dto.ts create mode 100644 frontend/src/app/app.configs.ts diff --git a/backend/src/base/location/location-db.service.ts b/backend/src/base/location/location-db.service.ts index 2291a8b..597da35 100644 --- a/backend/src/base/location/location-db.service.ts +++ b/backend/src/base/location/location-db.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { LocationEntity } from './location.entity'; -import { TreeRepository } from 'typeorm'; +import { SelectQueryBuilder, TreeRepository } from 'typeorm'; import { DeepPartial } from 'typeorm/common/DeepPartial'; @Injectable() @@ -11,8 +11,21 @@ export class LocationDbService { private readonly repo: TreeRepository, ) {} - public async getCount() { - return this.repo.count(); + private searchQueryBuilder( + query: SelectQueryBuilder, + searchTerm: string, + ): SelectQueryBuilder { + return query.where('l.name ilike :searchTerm', { + searchTerm: `%${searchTerm}%`, + }); + } + + public async getCount(searchTerm?: string) { + let query = this.repo.createQueryBuilder('l'); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + return query.getCount(); } public async findAll( @@ -20,6 +33,7 @@ export class LocationDbService { limit?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { let query = this.repo .createQueryBuilder('l') @@ -27,6 +41,10 @@ export class LocationDbService { .limit(limit ?? 100) .offset(offset ?? 0); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + if (sortCol) { if (sortCol.startsWith('parent.')) { query = query.orderBy( diff --git a/backend/src/base/location/location.controller.ts b/backend/src/base/location/location.controller.ts index 36641b7..1737941 100644 --- a/backend/src/base/location/location.controller.ts +++ b/backend/src/base/location/location.controller.ts @@ -12,6 +12,7 @@ import { LocationGetQueryDto } from './dto/location-get-query.dto'; import { LocationCreateDto } from './dto/location-create.dto'; import { LocationUpdateDto } from './dto/location-update.dto'; import { PaginationDto } from '../../shared/dto/pagination.dto'; +import { SearchDto } from '../../shared/dto/search.dto'; @Controller('location') export class LocationController { @@ -23,8 +24,8 @@ export class LocationController { responseType: CountDto, roles: [Role.LocationView], }) - public getCount(): Promise { - return this.service.getCount(); + public getCount(@Query() search: SearchDto): Promise { + return this.service.getCount(search.searchTerm); } @Endpoint(EndpointType.GET, { @@ -36,12 +37,14 @@ export class LocationController { public async getAll( @Query() pagination: PaginationDto, @Query() querys: LocationGetQueryDto, + @Query() search: SearchDto, ): Promise { return this.service.findAll( pagination.offset, pagination.limit, querys.sortCol, querys.sortDir, + search.searchTerm, ); } diff --git a/backend/src/base/location/location.service.ts b/backend/src/base/location/location.service.ts index 70e577b..4e6544d 100644 --- a/backend/src/base/location/location.service.ts +++ b/backend/src/base/location/location.service.ts @@ -20,12 +20,14 @@ export class LocationService { limit?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { const locations = await this.dbService.findAll( offset, limit, sortCol, sortDir, + searchTerm, ); return plainToInstance(LocationDto, locations); } @@ -44,8 +46,8 @@ export class LocationService { } } - public async getCount() { - const count = await this.dbService.getCount(); + public async getCount(searchTerm?: string) { + const count = await this.dbService.getCount(searchTerm); return plainToInstance(CountDto, { count }); } diff --git a/backend/src/inventory/device-group/device-group-db.service.spec.ts b/backend/src/inventory/device-group/device-group-db.service.spec.ts index 5e03e99..fdf9859 100644 --- a/backend/src/inventory/device-group/device-group-db.service.spec.ts +++ b/backend/src/inventory/device-group/device-group-db.service.spec.ts @@ -85,7 +85,10 @@ describe('DeviceGroupDbService', () => { expect(repoMock.createQueryBuilder).toHaveBeenCalled(); expect(repoMock.createQueryBuilder().limit).toHaveBeenCalledWith(10); expect(repoMock.createQueryBuilder().offset).toHaveBeenCalledWith(10); - expect(repoMock.createQueryBuilder().orderBy).toHaveBeenCalledWith('dg.name', 'ASC'); + expect(repoMock.createQueryBuilder().orderBy).toHaveBeenCalledWith( + 'dg.name', + 'ASC', + ); expect(repoMock.createQueryBuilder().getMany).toHaveBeenCalled(); }); }); diff --git a/backend/src/inventory/device-group/device-group-db.service.ts b/backend/src/inventory/device-group/device-group-db.service.ts index f94fb30..afcc8ab 100644 --- a/backend/src/inventory/device-group/device-group-db.service.ts +++ b/backend/src/inventory/device-group/device-group-db.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { DeepPartial } from 'typeorm/common/DeepPartial'; import { DeviceGroupEntity } from './device-group.entity'; @@ -11,8 +11,21 @@ export class DeviceGroupDbService { private readonly repo: Repository, ) {} - public async getCount() { - return this.repo.count(); + private searchQueryBuilder( + query: SelectQueryBuilder, + searchTerm: string, + ): SelectQueryBuilder { + return query.where('dg.name ilike :searchTerm', { + searchTerm: `%${searchTerm}%`, + }); + } + + public async getCount(searchTerm?: string) { + let query = this.repo.createQueryBuilder('dg'); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + return query.getCount(); } public async findAll( @@ -20,12 +33,17 @@ export class DeviceGroupDbService { limit?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { let query = this.repo .createQueryBuilder('dg') .limit(limit ?? 100) .offset(offset ?? 0); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + if (sortCol) { query = query.orderBy(`dg.${sortCol}`, sortDir ?? 'ASC'); } else { diff --git a/backend/src/inventory/device-group/device-group.controller.ts b/backend/src/inventory/device-group/device-group.controller.ts index 6cb7eff..fea158b 100644 --- a/backend/src/inventory/device-group/device-group.controller.ts +++ b/backend/src/inventory/device-group/device-group.controller.ts @@ -12,6 +12,7 @@ import { DeviceGroupUpdateDto } from './dto/device-group-update.dto'; import { DeviceGroupCreateDto } from './dto/device-group-create.dto'; import { DeviceGroupGetQueryDto } from './dto/device-group-get-query.dto'; import { PaginationDto } from '../../shared/dto/pagination.dto'; +import { SearchDto } from '../../shared/dto/search.dto'; @Controller('device-group') export class DeviceGroupController { @@ -23,8 +24,8 @@ export class DeviceGroupController { responseType: CountDto, roles: [Role.DeviceTypeView], }) - public getCount(): Promise { - return this.service.getCount(); + public getCount(@Query() search: SearchDto): Promise { + return this.service.getCount(search.searchTerm); } @Endpoint(EndpointType.GET, { @@ -36,12 +37,14 @@ export class DeviceGroupController { public async getAll( @Query() pagination: PaginationDto, @Query() querys: DeviceGroupGetQueryDto, + @Query() search: SearchDto, ): Promise { return this.service.findAll( pagination.offset, pagination.limit, querys.sortCol, querys.sortDir, + search.searchTerm, ); } diff --git a/backend/src/inventory/device-group/device-group.service.ts b/backend/src/inventory/device-group/device-group.service.ts index 58179ca..6c36807 100644 --- a/backend/src/inventory/device-group/device-group.service.ts +++ b/backend/src/inventory/device-group/device-group.service.ts @@ -19,12 +19,14 @@ export class DeviceGroupService { limit?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { const entities = await this.dbService.findAll( offset, limit, sortCol, sortDir, + searchTerm, ); return plainToInstance(DeviceGroupDto, entities); } @@ -43,8 +45,8 @@ export class DeviceGroupService { } } - public async getCount() { - const count = await this.dbService.getCount(); + public async getCount(searchTerm?: string) { + const count = await this.dbService.getCount(searchTerm); return plainToInstance(CountDto, { count }); } diff --git a/backend/src/inventory/device-type/device-type-db.service.spec.ts b/backend/src/inventory/device-type/device-type-db.service.spec.ts index 22ae566..360f13b 100644 --- a/backend/src/inventory/device-type/device-type-db.service.spec.ts +++ b/backend/src/inventory/device-type/device-type-db.service.spec.ts @@ -102,7 +102,10 @@ describe('DeviceTypeDbService', () => { expect(repoMock.createQueryBuilder).toHaveBeenCalled(); expect(repoMock.createQueryBuilder().limit).toHaveBeenCalledWith(10); expect(repoMock.createQueryBuilder().offset).toHaveBeenCalledWith(10); - expect(repoMock.createQueryBuilder().orderBy).toHaveBeenCalledWith('dt.name', 'ASC'); + expect(repoMock.createQueryBuilder().orderBy).toHaveBeenCalledWith( + 'dt.name', + 'ASC', + ); expect(repoMock.createQueryBuilder().getMany).toHaveBeenCalled(); }); }); diff --git a/backend/src/inventory/device-type/device-type-db.service.ts b/backend/src/inventory/device-type/device-type-db.service.ts index 042c781..8edefe0 100644 --- a/backend/src/inventory/device-type/device-type-db.service.ts +++ b/backend/src/inventory/device-type/device-type-db.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { DeviceTypeEntity } from './device-type.entity'; import { DeepPartial } from 'typeorm/common/DeepPartial'; @@ -11,8 +11,21 @@ export class DeviceTypeDbService { private readonly repo: Repository, ) {} - public async getCount() { - return this.repo.count(); + private searchQueryBuilder( + query: SelectQueryBuilder, + searchTerm: string, + ): SelectQueryBuilder { + return query.where('dt.name ilike :searchTerm', { + searchTerm: `%${searchTerm}%`, + }); + } + + public async getCount(searchTerm?: string) { + let query = this.repo.createQueryBuilder('dt'); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + return query.getCount(); } public async findAll( @@ -20,12 +33,17 @@ export class DeviceTypeDbService { limit?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { let query = this.repo .createQueryBuilder('dt') .limit(limit ?? 100) .offset(offset ?? 0); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + if (sortCol) { query = query.orderBy(`dt.${sortCol}`, sortDir ?? 'ASC'); } else { diff --git a/backend/src/inventory/device-type/device-type.controller.ts b/backend/src/inventory/device-type/device-type.controller.ts index 2b21927..a0ac1af 100644 --- a/backend/src/inventory/device-type/device-type.controller.ts +++ b/backend/src/inventory/device-type/device-type.controller.ts @@ -12,6 +12,7 @@ import { DeviceTypeUpdateDto } from './dto/device-type-update.dto'; import { DeviceTypeGetQueryDto } from './dto/device-type-get-query.dto'; import { CountDto } from '../../shared/dto/count.dto'; import { PaginationDto } from '../../shared/dto/pagination.dto'; +import { SearchDto } from '../../shared/dto/search.dto'; @Controller('device-type') export class DeviceTypeController { @@ -23,8 +24,8 @@ export class DeviceTypeController { responseType: CountDto, roles: [Role.DeviceTypeView], }) - public getCount(): Promise { - return this.service.getCount(); + public getCount(@Query() search: SearchDto): Promise { + return this.service.getCount(search.searchTerm); } @Endpoint(EndpointType.GET, { @@ -36,12 +37,14 @@ export class DeviceTypeController { public async getAll( @Query() pagination: PaginationDto, @Query() querys: DeviceTypeGetQueryDto, + @Query() search: SearchDto, ): Promise { return this.service.findAll( pagination.offset, pagination.limit, querys.sortCol, querys.sortDir, + search.searchTerm, ); } diff --git a/backend/src/inventory/device-type/device-type.service.ts b/backend/src/inventory/device-type/device-type.service.ts index 6795e75..8f06bee 100644 --- a/backend/src/inventory/device-type/device-type.service.ts +++ b/backend/src/inventory/device-type/device-type.service.ts @@ -19,12 +19,14 @@ export class DeviceTypeService { limit?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { const entities = await this.dbService.findAll( offset, limit, sortCol, sortDir, + searchTerm, ); return plainToInstance(DeviceTypeDto, entities); } @@ -43,8 +45,8 @@ export class DeviceTypeService { } } - public async getCount() { - const count = await this.dbService.getCount(); + public async getCount(searchTerm?: string) { + const count = await this.dbService.getCount(searchTerm); return plainToInstance(CountDto, { count }); } diff --git a/backend/src/inventory/device/device-db.service.ts b/backend/src/inventory/device/device-db.service.ts index 7c234b2..c82a4c6 100644 --- a/backend/src/inventory/device/device-db.service.ts +++ b/backend/src/inventory/device/device-db.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { DeepPartial } from 'typeorm/common/DeepPartial'; import { DeviceEntity } from './device.entity'; @@ -11,8 +11,21 @@ export class DeviceDbService { private readonly repo: Repository, ) {} - public async getCount() { - return this.repo.count(); + private searchQueryBuilder( + query: SelectQueryBuilder, + searchTerm: string, + ): SelectQueryBuilder { + return query.where('d.name ilike :searchTerm', { + searchTerm: `%${searchTerm}%`, + }); + } + + public async getCount(searchTerm?: string) { + let query = this.repo.createQueryBuilder('d'); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + return query.getCount(); } public async findAll( @@ -23,6 +36,7 @@ export class DeviceDbService { locationId?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { let query = this.repo .createQueryBuilder('d') @@ -33,6 +47,10 @@ export class DeviceDbService { .leftJoinAndSelect('d.location', 'l') .leftJoinAndSelect('l.parent', 'lp'); + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + if (sortCol) { if (sortCol.startsWith('type.')) { query = query.orderBy( diff --git a/backend/src/inventory/device/device.controller.ts b/backend/src/inventory/device/device.controller.ts index d454719..2b1e6d3 100644 --- a/backend/src/inventory/device/device.controller.ts +++ b/backend/src/inventory/device/device.controller.ts @@ -12,6 +12,7 @@ import { DeviceGetQueryDto } from './dto/device-get-query.dto'; 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'; @Controller('device') export class DeviceController { @@ -23,8 +24,8 @@ export class DeviceController { responseType: CountDto, roles: [Role.DeviceView], }) - public getCount(): Promise { - return this.service.getCount(); + public getCount(@Query() search: SearchDto): Promise { + return this.service.getCount(search.searchTerm); } @Endpoint(EndpointType.GET, { @@ -36,6 +37,7 @@ export class DeviceController { public async getAll( @Query() pagination: PaginationDto, @Query() querys: DeviceGetQueryDto, + @Query() search: SearchDto, ): Promise { return this.service.findAll( pagination.offset, @@ -45,6 +47,7 @@ export class DeviceController { querys.locationId, querys.sortCol, querys.sortDir, + search.searchTerm, ); } diff --git a/backend/src/inventory/device/device.service.ts b/backend/src/inventory/device/device.service.ts index c25d758..80e328e 100644 --- a/backend/src/inventory/device/device.service.ts +++ b/backend/src/inventory/device/device.service.ts @@ -22,6 +22,7 @@ export class DeviceService { locationId?: number, sortCol?: string, sortDir?: 'ASC' | 'DESC', + searchTerm?: string, ) { const entities = await this.dbService.findAll( offset, @@ -31,6 +32,7 @@ export class DeviceService { locationId, sortCol, sortDir, + searchTerm, ); return plainToInstance(DeviceDto, entities); } @@ -49,8 +51,8 @@ export class DeviceService { } } - public async getCount() { - const count = await this.dbService.getCount(); + public async getCount(searchTerm?: string) { + const count = await this.dbService.getCount(searchTerm); return plainToInstance(CountDto, { count }); } diff --git a/backend/src/shared/dto/search.dto.ts b/backend/src/shared/dto/search.dto.ts new file mode 100644 index 0000000..e4b3836 --- /dev/null +++ b/backend/src/shared/dto/search.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class SearchDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + searchTerm?: string; +} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 4fa21f2..856b897 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -21,6 +21,7 @@ import {authConfig} from './core/auth/auth-config'; 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'; registerLocaleData(de); @@ -47,5 +48,6 @@ export const appConfig: ApplicationConfig = { importProvidersFrom(FormsModule), provideAnimationsAsync(), {provide: BASE_PATH, useValue: ''}, + provideAppConfigs(), ] }; diff --git a/frontend/src/app/app.configs.ts b/frontend/src/app/app.configs.ts new file mode 100644 index 0000000..f3ae7e2 --- /dev/null +++ b/frontend/src/app/app.configs.ts @@ -0,0 +1,12 @@ +import {EnvironmentProviders, InjectionToken, makeEnvironmentProviders} from '@angular/core'; + +export const SEARCH_DEBOUNCE_TIME = new InjectionToken('SEARCH_DEBOUNCE_TIME'); + +export function provideAppConfigs(): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: SEARCH_DEBOUNCE_TIME, + useValue: 300, + } + ]); +} diff --git a/frontend/src/app/pages/base/locations/locations.component.html b/frontend/src/app/pages/base/locations/locations.component.html index c9c2a03..6bea5a5 100644 --- a/frontend/src/app/pages/base/locations/locations.component.html +++ b/frontend/src/app/pages/base/locations/locations.component.html @@ -1,3 +1,15 @@

Orte/Fahrzeuge

- +
+
+ +
+
+ + + + + + +
+
diff --git a/frontend/src/app/pages/base/locations/locations.component.ts b/frontend/src/app/pages/base/locations/locations.component.ts index 65b0f01..fee3830 100644 --- a/frontend/src/app/pages/base/locations/locations.component.ts +++ b/frontend/src/app/pages/base/locations/locations.component.ts @@ -1,8 +1,12 @@ -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {HasRoleDirective} from '../../../core/auth/has-role.directive'; import {NzButtonModule} from 'ng-zorro-antd/button'; import {RouterLink} from '@angular/router'; import {LocationListComponent} from './location-list/location-list.component'; +import {NzGridModule} from 'ng-zorro-antd/grid'; +import {NzInputModule} from 'ng-zorro-antd/input'; +import {NzIconModule} from 'ng-zorro-antd/icon'; +import {LocationsService} from './locations.service'; @Component({ selector: 'ofs-locations', @@ -11,10 +15,24 @@ import {LocationListComponent} from './location-list/location-list.component'; RouterLink, LocationListComponent, NzButtonModule, + NzGridModule, + NzInputModule, + NzIconModule, ], templateUrl: './locations.component.html', styleUrl: './locations.component.less' }) -export class LocationsComponent { +export class LocationsComponent implements OnInit { + constructor(private readonly service: LocationsService) { + } + + 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/base/locations/locations.service.ts b/frontend/src/app/pages/base/locations/locations.service.ts index a289a85..0c0ff3a 100644 --- a/frontend/src/app/pages/base/locations/locations.service.ts +++ b/frontend/src/app/pages/base/locations/locations.service.ts @@ -1,6 +1,8 @@ -import {Injectable, signal} from '@angular/core'; +import {Inject, Injectable, signal} from '@angular/core'; import {LocationDto} from '@backend/model/locationDto'; import {LocationService} from '@backend/api/location.service'; +import {BehaviorSubject, debounceTime, distinctUntilChanged, filter} from 'rxjs'; +import {SEARCH_DEBOUNCE_TIME} from '../../../app.configs'; @Injectable({ providedIn: 'root' @@ -14,15 +16,31 @@ export class LocationsService { locationsLoadError = signal(false); sortCol?: string; sortDir?: string; + searchTerm$ = new BehaviorSubject<{propagate: boolean, value: string}>({propagate: true, value: ''}); + private searchTerm?: string; - constructor(private readonly locationService: LocationService) { + + constructor( + private readonly locationService: LocationService, + @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.locationsLoading.set(true); - this.locationService.locationControllerGetCount() + this.locationService.locationControllerGetCount(this.searchTerm) .subscribe((count) => this.total.set(count.count)); - this.locationService.locationControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir) + this.locationService.locationControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir, this.searchTerm) .subscribe({ next: (users) => { this.locations.set(users); @@ -44,4 +62,14 @@ export class LocationsService { 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-groups.component.html b/frontend/src/app/pages/inventory/device-groups/device-groups.component.html index b76a791..7dae087 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-groups.component.html +++ b/frontend/src/app/pages/inventory/device-groups/device-groups.component.html @@ -1,3 +1,17 @@

Geräte-Gruppen

- +
+
+ +
+
+ + + + + + +
+
diff --git a/frontend/src/app/pages/inventory/device-groups/device-groups.component.ts b/frontend/src/app/pages/inventory/device-groups/device-groups.component.ts index 22fd44c..0335f9d 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-groups.component.ts +++ b/frontend/src/app/pages/inventory/device-groups/device-groups.component.ts @@ -1,23 +1,40 @@ -import { Component } from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {HasRoleDirective} from '../../../core/auth/has-role.directive'; -import {NzButtonComponent} from 'ng-zorro-antd/button'; +import {NzButtonModule} from 'ng-zorro-antd/button'; import {NzWaveDirective} from 'ng-zorro-antd/core/wave'; import {RouterLink} from '@angular/router'; import {DeviceGroupListComponent} from './device-group-list/device-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 {DeviceGroupsService} from './device-groups.service'; @Component({ selector: 'ofs-device-groups', imports: [ HasRoleDirective, - NzButtonComponent, NzWaveDirective, RouterLink, - DeviceGroupListComponent + DeviceGroupListComponent, + NzButtonModule, + NzGridModule, + NzInputModule, + NzIconModule, ], standalone: true, templateUrl: './device-groups.component.html', styleUrl: './device-groups.component.less' }) -export class DeviceGroupsComponent { +export class DeviceGroupsComponent implements OnInit { + constructor(private service: DeviceGroupsService) { + } + 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/device-groups/device-groups.service.ts b/frontend/src/app/pages/inventory/device-groups/device-groups.service.ts index 71b5b52..fc3061a 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-groups.service.ts +++ b/frontend/src/app/pages/inventory/device-groups/device-groups.service.ts @@ -1,6 +1,8 @@ -import {Injectable, signal} from '@angular/core'; +import {Inject, Injectable, signal} from '@angular/core'; import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; import {DeviceGroupService} from '@backend/api/deviceGroup.service'; +import {BehaviorSubject, debounceTime, distinctUntilChanged, filter} from 'rxjs'; +import {SEARCH_DEBOUNCE_TIME} from '../../../app.configs'; @Injectable({ providedIn: 'root' @@ -14,15 +16,29 @@ export class DeviceGroupsService { entitiesLoadError = signal(false); sortCol?: string; sortDir?: string; + searchTerm$ = new BehaviorSubject<{propagate: boolean, value: string}>({propagate: true, value: ''}); + private searchTerm?: string; - constructor(private readonly apiService: DeviceGroupService) { + constructor(private readonly apiService: DeviceGroupService, + @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.deviceGroupControllerGetCount() + this.apiService.deviceGroupControllerGetCount(this.searchTerm) .subscribe((count) => this.total.set(count.count)); - this.apiService.deviceGroupControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir) + this.apiService.deviceGroupControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir, this.searchTerm) .subscribe({ next: (users) => { this.entities.set(users); @@ -44,4 +60,14 @@ export class DeviceGroupsService { 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-types/device-types.component.html b/frontend/src/app/pages/inventory/device-types/device-types.component.html index 657ded3..e926a01 100644 --- a/frontend/src/app/pages/inventory/device-types/device-types.component.html +++ b/frontend/src/app/pages/inventory/device-types/device-types.component.html @@ -1,3 +1,15 @@

Geräte-Typen

- +
+
+ +
+
+ + + + + + +
+
diff --git a/frontend/src/app/pages/inventory/device-types/device-types.component.ts b/frontend/src/app/pages/inventory/device-types/device-types.component.ts index 5fea3d6..9f5be48 100644 --- a/frontend/src/app/pages/inventory/device-types/device-types.component.ts +++ b/frontend/src/app/pages/inventory/device-types/device-types.component.ts @@ -1,23 +1,40 @@ -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {HasRoleDirective} from '../../../core/auth/has-role.directive'; -import {NzButtonComponent} from 'ng-zorro-antd/button'; +import {NzButtonModule} from 'ng-zorro-antd/button'; import {NzWaveDirective} from 'ng-zorro-antd/core/wave'; import {RouterLink} from '@angular/router'; import {DeviceTypeListComponent} from './device-type-list/device-type-list.component'; +import {NzGridModule} from 'ng-zorro-antd/grid'; +import {NzInputModule} from 'ng-zorro-antd/input'; +import {NzIconModule} from 'ng-zorro-antd/icon'; +import {DeviceTypesService} from './device-types.service'; @Component({ selector: 'ofs-device-types', imports: [ HasRoleDirective, - NzButtonComponent, NzWaveDirective, RouterLink, - DeviceTypeListComponent + DeviceTypeListComponent, + NzButtonModule, + NzGridModule, + NzInputModule, + NzIconModule, ], standalone: true, templateUrl: './device-types.component.html', styleUrl: './device-types.component.less' }) -export class DeviceTypesComponent { +export class DeviceTypesComponent implements OnInit { + constructor(private service: DeviceTypesService) { + } + 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/device-types/device-types.service.ts b/frontend/src/app/pages/inventory/device-types/device-types.service.ts index 4bf60a1..4ad5e0b 100644 --- a/frontend/src/app/pages/inventory/device-types/device-types.service.ts +++ b/frontend/src/app/pages/inventory/device-types/device-types.service.ts @@ -1,6 +1,8 @@ -import {Injectable, signal} from '@angular/core'; +import {Inject, Injectable, signal} from '@angular/core'; import {DeviceTypeDto} from '@backend/model/deviceTypeDto'; import {DeviceTypeService} from '@backend/api/deviceType.service'; +import {BehaviorSubject, debounceTime, distinctUntilChanged, filter} from 'rxjs'; +import {SEARCH_DEBOUNCE_TIME} from '../../../app.configs'; @Injectable({ providedIn: 'root' @@ -14,15 +16,29 @@ export class DeviceTypesService { entitiesLoadError = signal(false); sortCol?: string; sortDir?: string; + searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); + private searchTerm?: string; - constructor(private readonly apiService: DeviceTypeService) { + constructor(private readonly apiService: DeviceTypeService, + @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.deviceTypeControllerGetCount() + this.apiService.deviceTypeControllerGetCount(this.searchTerm) .subscribe((count) => this.total.set(count.count)); - this.apiService.deviceTypeControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir) + this.apiService.deviceTypeControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir, this.searchTerm) .subscribe({ next: (users) => { this.entities.set(users); @@ -44,4 +60,14 @@ export class DeviceTypesService { 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/devices/devices.component.html b/frontend/src/app/pages/inventory/devices/devices.component.html index 83f2476..30093a6 100644 --- a/frontend/src/app/pages/inventory/devices/devices.component.html +++ b/frontend/src/app/pages/inventory/devices/devices.component.html @@ -1,3 +1,15 @@

Geräte

- +
+
+ +
+
+ + + + + + +
+
diff --git a/frontend/src/app/pages/inventory/devices/devices.component.ts b/frontend/src/app/pages/inventory/devices/devices.component.ts index 95053ab..4f79b5c 100644 --- a/frontend/src/app/pages/inventory/devices/devices.component.ts +++ b/frontend/src/app/pages/inventory/devices/devices.component.ts @@ -1,9 +1,15 @@ -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {HasRoleDirective} from '../../../core/auth/has-role.directive'; import {NzButtonComponent} from 'ng-zorro-antd/button'; import {NzWaveDirective} from 'ng-zorro-antd/core/wave'; import {RouterLink} from '@angular/router'; import {DeviceListComponent} from './device-list/device-list.component'; +import {NzGridModule} from 'ng-zorro-antd/grid'; +import {NzIconModule} from 'ng-zorro-antd/icon'; +import { + NzInputModule +} from 'ng-zorro-antd/input'; +import {DevicesService} from './devices.service'; @Component({ selector: 'ofs-devices', @@ -12,12 +18,25 @@ import {DeviceListComponent} from './device-list/device-list.component'; NzButtonComponent, NzWaveDirective, RouterLink, - DeviceListComponent + DeviceListComponent, + NzGridModule, + NzInputModule, + NzIconModule, ], standalone: true, templateUrl: './devices.component.html', styleUrl: './devices.component.less' }) -export class DevicesComponent { +export class DevicesComponent implements OnInit { + constructor(private readonly service: DevicesService) { + } + 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/devices/devices.service.ts b/frontend/src/app/pages/inventory/devices/devices.service.ts index b07e3ac..32fc2b9 100644 --- a/frontend/src/app/pages/inventory/devices/devices.service.ts +++ b/frontend/src/app/pages/inventory/devices/devices.service.ts @@ -1,6 +1,8 @@ -import {Injectable, signal} from '@angular/core'; +import {Inject, Injectable, signal} from '@angular/core'; import {DeviceDto} from '@backend/model/deviceDto'; import {DeviceService} from '@backend/api/device.service'; +import {BehaviorSubject, debounceTime, distinctUntilChanged, filter} from 'rxjs'; +import {SEARCH_DEBOUNCE_TIME} from '../../../app.configs'; @Injectable({ providedIn: 'root' @@ -14,16 +16,31 @@ export class DevicesService { entitiesLoadError = signal(false); sortCol?: string; sortDir?: string; + searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); + private searchTerm?: string; - constructor(private readonly apiService: DeviceService) { + constructor( + private readonly apiService: DeviceService, + @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.deviceControllerGetCount() + this.apiService.deviceControllerGetCount(this.searchTerm) .subscribe((count) => this.total.set(count.count)); this.apiService.deviceControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, undefined, - undefined, undefined, this.sortCol, this.sortDir) + undefined, undefined, this.sortCol, this.sortDir, this.searchTerm) .subscribe({ next: (entities) => { this.entities.set(entities); @@ -45,4 +62,14 @@ export class DevicesService { 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/openapi/backend.yml b/openapi/backend.yml index 995f3f3..138e4ed 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -512,7 +512,12 @@ paths: get: description: Gibt die Anzahl aller Device-Typen zurück operationId: DeviceTypeController_getCount - parameters: [] + parameters: + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' @@ -552,6 +557,11 @@ paths: in: query schema: type: string + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' @@ -676,7 +686,12 @@ paths: get: description: Gibt die Anzahl aller Geräte-Gruppen zurück operationId: DeviceGroupController_getCount - parameters: [] + parameters: + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' @@ -716,6 +731,11 @@ paths: in: query schema: type: string + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' @@ -840,7 +860,12 @@ paths: get: description: Gibt die Anzahl aller Geräte zurück operationId: DeviceController_getCount - parameters: [] + parameters: + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' @@ -898,6 +923,11 @@ paths: in: query schema: type: string + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' @@ -1022,7 +1052,12 @@ paths: get: description: Gibt die Anzahl aller Standorte zurück operationId: LocationController_getCount - parameters: [] + parameters: + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' @@ -1062,6 +1097,11 @@ paths: in: query schema: type: string + - name: searchTerm + required: false + in: query + schema: + type: string responses: '200': description: '' From 51bf0a6701a29089f76b13f2591e6418d9a333b1 Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Tue, 27 May 2025 16:30:02 +0200 Subject: [PATCH 02/16] =?UTF-8?q?=F0=9F=92=AA=20create/=20update=20relatio?= =?UTF-8?q?n=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/app.configs.ts | 7 +- .../location-create.component.html | 17 +-- .../location-create.component.ts | 31 +++--- .../location-create.service.ts | 43 ++++---- .../location-detail.component.html | 17 ++- .../location-detail.component.ts | 38 +++---- .../location-detail.service.ts | 36 ++++--- .../device-group-create.service.ts | 2 +- .../device-create.component.html | 48 ++++----- .../device-create/device-create.component.ts | 28 ++--- .../device-create/device-create.service.ts | 101 ++++++++++-------- .../device-detail.component.html | 49 +++++---- .../device-detail/device-detail.component.ts | 12 +-- .../device-detail/device-detail.service.ts | 89 ++++++++------- 14 files changed, 271 insertions(+), 247 deletions(-) diff --git a/frontend/src/app/app.configs.ts b/frontend/src/app/app.configs.ts index f3ae7e2..5314b67 100644 --- a/frontend/src/app/app.configs.ts +++ b/frontend/src/app/app.configs.ts @@ -1,12 +1,17 @@ import {EnvironmentProviders, InjectionToken, makeEnvironmentProviders} from '@angular/core'; export const SEARCH_DEBOUNCE_TIME = new InjectionToken('SEARCH_DEBOUNCE_TIME'); +export const SELECT_ITEMS_COUNT = new InjectionToken('SELECT_ITEMS_COUNT'); export function provideAppConfigs(): EnvironmentProviders { return makeEnvironmentProviders([ { provide: SEARCH_DEBOUNCE_TIME, useValue: 300, - } + }, + { + provide: SELECT_ITEMS_COUNT, + useValue: 20, + }, ]); } diff --git a/frontend/src/app/pages/base/locations/location-create/location-create.component.html b/frontend/src/app/pages/base/locations/location-create/location-create.component.html index 2658401..3f1ba83 100644 --- a/frontend/src/app/pages/base/locations/location-create/location-create.component.html +++ b/frontend/src/app/pages/base/locations/location-create/location-create.component.html @@ -49,21 +49,22 @@

Ort/Fahrzeug erstellen

Übergeordneter Ort/Fahrzeug + - @for (item of parents(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (parentsIsLoading()) { + } @else { + @for (item of parents(); track item) { + + } } - + diff --git a/frontend/src/app/pages/base/locations/location-create/location-create.component.ts b/frontend/src/app/pages/base/locations/location-create/location-create.component.ts index bc070fe..9faae41 100644 --- a/frontend/src/app/pages/base/locations/location-create/location-create.component.ts +++ b/frontend/src/app/pages/base/locations/location-create/location-create.component.ts @@ -1,4 +1,4 @@ -import {Component, OnDestroy, OnInit, Signal} from '@angular/core'; +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'; @@ -26,7 +26,7 @@ interface LocationCreateForm { templateUrl: './location-create.component.html', styleUrl: './location-create.component.less' }) -export class LocationCreateComponent implements OnDestroy, OnInit { +export class LocationCreateComponent implements OnDestroy { types = LocationTypes.all; parentsIsLoading: Signal; @@ -52,35 +52,30 @@ export class LocationCreateComponent implements OnDestroy, OnInit { private destroy$ = new Subject(); constructor( - private readonly locationCreateService: LocationCreateService, + private readonly service: LocationCreateService, private readonly inAppMessagingService: InAppMessageService ) { - this.createLoading = this.locationCreateService.createLoading; - this.parentsIsLoading = this.locationCreateService.parentsIsLoading; - this.parents = this.locationCreateService.parents; + this.createLoading = this.service.createLoading; + this.parentsIsLoading = this.service.parentsIsLoading; + this.parents = this.service.parents; - this.locationCreateService.createLoadingError + this.service.createLoadingError .pipe(takeUntil(this.destroy$)) .subscribe((x) => this.inAppMessagingService.showError(x)); - this.locationCreateService.createLoadingSuccess + this.service.createLoadingSuccess .pipe(takeUntil(this.destroy$)) .subscribe(() => this.inAppMessagingService.showSuccess('Änderungen gespeichert')); } - - ngOnInit(): void { - this.locationCreateService.loadParents(true); - } - submit() { - this.locationCreateService.create(this.form.getRawValue()); - } - - loadMore() { - this.locationCreateService.loadParents(); + this.service.create(this.form.getRawValue()); } ngOnDestroy(): void { this.destroy$.next(); } + + onSearchParent(search: string) { + this.service.onSearchParent(search); + } } diff --git a/frontend/src/app/pages/base/locations/location-create/location-create.service.ts b/frontend/src/app/pages/base/locations/location-create/location-create.service.ts index 51ff2e8..66f551b 100644 --- a/frontend/src/app/pages/base/locations/location-create/location-create.service.ts +++ b/frontend/src/app/pages/base/locations/location-create/location-create.service.ts @@ -1,10 +1,11 @@ -import {Injectable, signal} from '@angular/core'; -import {Subject} from 'rxjs'; +import {Inject, Injectable, signal} from '@angular/core'; +import {BehaviorSubject, debounceTime, filter, Subject} from 'rxjs'; import {Router} from '@angular/router'; import {HttpErrorResponse} from '@angular/common/http'; import {LocationService} from '@backend/api/location.service'; import {LocationCreateDto} from '@backend/model/locationCreateDto'; import {LocationDto} from '@backend/model/locationDto'; +import {SEARCH_DEBOUNCE_TIME, SELECT_ITEMS_COUNT} from '../../../../app.configs'; @Injectable({ providedIn: 'root' @@ -16,10 +17,22 @@ export class LocationCreateService { parentsIsLoading = signal(false); parents = signal([]); - private parentsPage = 0; - private parentsItemsPerPage = 10; + parentsSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + parentsSearch = ''; - constructor(private readonly locationService: LocationService, private readonly router: Router) { + constructor( + private readonly locationService: LocationService, + private readonly router: Router, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, + ) { + this.parentsSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.parentsSearch = x.value; + this.loadParents(); + }); } create(rawValue: LocationCreateDto) { @@ -38,29 +51,23 @@ export class LocationCreateService { }); } - loadParents(init = false) { - if (init) { - this.parentsPage = 0; - this.parents.set([]); - } + loadParents() { this.parentsIsLoading.set(true); this.locationService - .locationControllerGetAll(this.parentsItemsPerPage, this.parentsPage * this.parentsItemsPerPage) + .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.parentsSearch) .subscribe({ next: (parents) => { this.parentsIsLoading.set(false); - const newParents = [ - ...this.parents(), - ...parents, - ]; - this.parents.set(newParents); - this.parentsPage += 1; + this.parents.set(parents); }, error: () => { this.parentsIsLoading.set(false); this.parents.set([]); - this.parentsPage = 0; } }); } + + onSearchParent(value: string): void { + this.parentsSearch$.next({propagate: true, value}); + } } diff --git a/frontend/src/app/pages/base/locations/location-detail/location-detail.component.html b/frontend/src/app/pages/base/locations/location-detail/location-detail.component.html index 1cb0c60..ef62839 100644 --- a/frontend/src/app/pages/base/locations/location-detail/location-detail.component.html +++ b/frontend/src/app/pages/base/locations/location-detail/location-detail.component.html @@ -61,21 +61,20 @@

Der Ort/Fahrzeug wurde nicht gefunden!

Übergeordneter Ort/Fahrzeug - @for (item of parents(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (parentsIsLoading()) { + } @else { + @for (item of parents(); track item) { + + } } - + diff --git a/frontend/src/app/pages/base/locations/location-detail/location-detail.component.ts b/frontend/src/app/pages/base/locations/location-detail/location-detail.component.ts index 1419c45..911a821 100644 --- a/frontend/src/app/pages/base/locations/location-detail/location-detail.component.ts +++ b/frontend/src/app/pages/base/locations/location-detail/location-detail.component.ts @@ -60,39 +60,39 @@ export class LocationDetailComponent implements OnInit, OnDestroy { constructor( private readonly activatedRoute: ActivatedRoute, - private readonly locationDetailService: LocationDetailService, + private readonly service: LocationDetailService, private readonly inAppMessagingService: InAppMessageService, ) { - this.notFound = this.locationDetailService.notFound; - this.loading = this.locationDetailService.loading; - this.loadingError = this.locationDetailService.loadingError; - this.deleteLoading = this.locationDetailService.deleteLoading; - this.updateLoading = this.locationDetailService.updateLoading; - this.parentsIsLoading = this.locationDetailService.parentsIsLoading; - this.parents = this.locationDetailService.parents; + 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.parentsIsLoading = this.service.parentsIsLoading; + this.parents = this.service.parents; effect(() => { - const location = this.locationDetailService.location(); + const location = this.service.location(); if (location) this.form.patchValue(location); }); effect(() => { - const updateLoading = this.locationDetailService.loadingError(); + const updateLoading = this.service.loadingError(); if (updateLoading) { this.inAppMessagingService.showError('Fehler beim laden des Orts/Fahrzeugs.'); } }); - this.locationDetailService.deleteLoadingError + this.service.deleteLoadingError .pipe(takeUntil(this.destroy$)) .subscribe((x) => this.inAppMessagingService.showError(x)); - this.locationDetailService.deleteLoadingSuccess + this.service.deleteLoadingSuccess .pipe(takeUntil(this.destroy$)) .subscribe(() => this.inAppMessagingService.showSuccess('Ort/Fahrzeug gelöscht')); - this.locationDetailService.updateLoadingError + this.service.updateLoadingError .pipe(takeUntil(this.destroy$)) .subscribe(() => this.inAppMessagingService.showError('Fehler beim speichern.')); - this.locationDetailService.updateLoadingSuccess + this.service.updateLoadingSuccess .pipe(takeUntil(this.destroy$)) .subscribe(() => this.inAppMessagingService.showSuccess('Änderungen gespeichert')); } @@ -104,19 +104,19 @@ export class LocationDetailComponent implements OnInit, OnDestroy { ngOnInit(): void { this.activatedRoute.params.subscribe(params => { - this.locationDetailService.load(params['id']); + this.service.load(params['id']); }); } submit() { - this.locationDetailService.update(this.form.getRawValue()); + this.service.update(this.form.getRawValue()); } delete() { - this.locationDetailService.delete(); + this.service.delete(); } - loadMore() { - this.locationDetailService.loadParents(); + onSearchParent(search: string) { + this.service.onSearchParent(search); } } diff --git a/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts b/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts index d11f31e..8fa01f6 100644 --- a/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts +++ b/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts @@ -1,10 +1,11 @@ -import {Injectable, signal} from '@angular/core'; -import {Subject} from 'rxjs'; +import {Inject, Injectable, signal} from '@angular/core'; +import {BehaviorSubject, debounceTime, filter, Subject} from 'rxjs'; import {Router} from '@angular/router'; import {HttpErrorResponse} from '@angular/common/http'; import {LocationDto} from '@backend/model/locationDto'; import {LocationService} from '@backend/api/location.service'; import {LocationUpdateDto} from '@backend/model/locationUpdateDto'; +import {SEARCH_DEBOUNCE_TIME, SELECT_ITEMS_COUNT} from '../../../../app.configs'; @Injectable({ providedIn: 'root' @@ -24,19 +25,26 @@ export class LocationDetailService { updateLoadingSuccess = new Subject(); deleteLoadingSuccess = new Subject(); - private parentsPage = 0; - private parentsItemsPerPage = 10; + parentsSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + parentsSearch = ''; constructor( private readonly locationService: LocationService, private readonly router: Router, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, ) { + this.parentsSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.parentsSearch = x.value; + this.loadParents(); + }); } load(id: number) { this.id = id; - this.parentsPage = 0; - this.parents.set([]); this.loading.set(true); this.locationService.locationControllerGetOne(id) .subscribe({ @@ -47,7 +55,6 @@ export class LocationDetailService { if (newEntity.parent) { this.parents.set([newEntity.parent]); } - this.loadParents(); }, error: (err: HttpErrorResponse) => { if (err.status === 404) { @@ -57,7 +64,6 @@ export class LocationDetailService { this.loadingError.set(true); this.loading.set(false); this.parents.set([]); - this.parentsPage = 0; } }) } @@ -103,22 +109,20 @@ export class LocationDetailService { loadParents() { this.parentsIsLoading.set(true); this.locationService - .locationControllerGetAll(this.parentsItemsPerPage, this.parentsPage * this.parentsItemsPerPage) + .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.parentsSearch) .subscribe({ next: (parents) => { this.parentsIsLoading.set(false); - const newParents = [ - ...this.parents(), - ...parents.filter(x => x.id != this.location()?.parentId && x.id != this.id), - ]; - this.parents.set(newParents); - this.parentsPage += 1; + this.parents.set(parents.filter(x => x.id !== this.location()?.id)); }, error: () => { this.parentsIsLoading.set(false); this.parents.set([]); - this.parentsPage = 0; } }); } + + onSearchParent(value: string): void { + this.parentsSearch$.next({propagate: true, value}); + } } diff --git a/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts b/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts index 41cdbc9..da97651 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts +++ b/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts @@ -9,7 +9,7 @@ import {HttpErrorResponse} from '@angular/common/http'; providedIn: 'root' }) export class DeviceGroupCreateService { -createLoading = signal(false); + createLoading = signal(false); createLoadingError = new Subject(); createLoadingSuccess = new Subject(); diff --git a/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html b/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html index b7ee5c4..ecdb9fd 100644 --- a/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html +++ b/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html @@ -41,20 +41,20 @@

Geräte erstellen

Standort/Fahrzeug - @for (item of locations(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (locationsIsLoading()) { + } @else { + @for (item of locations(); track item) { + + } } - + @@ -62,20 +62,20 @@

Geräte erstellen

Geräte-Typ - @for (item of deviceTypes(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (deviceTypesIsLoading()) { + } @else { + @for (item of deviceTypes(); track item) { + + } } - + @@ -83,20 +83,20 @@

Geräte erstellen

Geräte-Gruppe - @for (item of deviceGroups(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (deviceGroupsIsLoading()) { + } @else { + @for (item of deviceGroups(); track item) { + + } } - + diff --git a/frontend/src/app/pages/inventory/devices/device-create/device-create.component.ts b/frontend/src/app/pages/inventory/devices/device-create/device-create.component.ts index e979b74..dde98f1 100644 --- a/frontend/src/app/pages/inventory/devices/device-create/device-create.component.ts +++ b/frontend/src/app/pages/inventory/devices/device-create/device-create.component.ts @@ -1,4 +1,4 @@ -import {Component, OnDestroy, OnInit, Signal} from '@angular/core'; +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'; @@ -35,7 +35,6 @@ interface DeviceCreateForm { locationId: FormControl; } -// TODO Geräte-Typ durch eintippen von filtern @Component({ selector: 'ofs-device-create', imports: [ReactiveFormsModule, NzButtonModule, NzFormModule, NzInputModule, NzCheckboxModule, NzInputNumberModule, NzDatePickerModule, NzOptionComponent, NzSelectComponent, NzSpinComponent], @@ -43,7 +42,7 @@ interface DeviceCreateForm { templateUrl: './device-create.component.html', styleUrl: './device-create.component.less' }) -export class DeviceCreateComponent implements OnInit, OnDestroy { +export class DeviceCreateComponent implements OnDestroy { states = DeviceTypes.all; form = new FormGroup({ @@ -133,25 +132,20 @@ export class DeviceCreateComponent implements OnInit, OnDestroy { this.service.create(this.form.getRawValue() as any); // TODO } - loadMoreLocations() { - this.service.loadMoreLocations(); - } - - loadMoreTypes() { - this.service.loadMoreTypes(); + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } - loadMoreGroups() { - this.service.loadMoreGroups(); + onSearchLocation(search: string) { + this.service.onSearchLocation(search); } - ngOnInit(): void { - this.service.loadMoreTypes(true); - this.service.loadMoreGroups(true); - this.service.loadMoreLocations(true); + onSearchType(search: string) { + this.service.onSearchType(search); } - ngOnDestroy(): void { - this.destroy$.next(); + onSearchGroup(search: string) { + this.service.onSearchGroup(search); } } diff --git a/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts b/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts index 82ce408..ec830e2 100644 --- a/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts +++ b/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts @@ -1,5 +1,5 @@ -import {Injectable, signal} from '@angular/core'; -import {Subject} from 'rxjs'; +import {Inject, Injectable, signal} from '@angular/core'; +import {BehaviorSubject, debounceTime, filter, Subject} from 'rxjs'; import {Router} from '@angular/router'; import {DeviceService} from '@backend/api/device.service'; import {HttpErrorResponse} from '@angular/common/http'; @@ -10,6 +10,7 @@ import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; 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'; @Injectable({ providedIn: 'root' @@ -19,20 +20,20 @@ export class DeviceCreateService { createLoadingError = new Subject(); createLoadingSuccess = new Subject(); + deviceTypesSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + deviceTypesSearch = ''; deviceTypes = signal([]); deviceTypesIsLoading = signal(false); - private deviceTypesPage = 0; - private deviceTypesItemsPerPage = 10; + deviceGroupsSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + deviceGroupsSearch = ''; deviceGroups = signal([]); deviceGroupsIsLoading = signal(false); - private deviceGroupsPage = 0; - private deviceGroupsItemsPerPage = 10; + locationSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + locationSearch = ''; locations = signal([]); locationsIsLoading = signal(false); - private locationsPage = 0; - private locationsItemsPerPage = 10; constructor( private readonly apiService: DeviceService, @@ -40,7 +41,30 @@ export class DeviceCreateService { private readonly apiDeviceGroupsService: DeviceGroupService, private readonly apiLocationsService: LocationService, private readonly router: Router, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, ) { + this.locationSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.locationSearch = x.value; + this.loadMoreLocations(); + }); + this.deviceTypesSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.deviceTypesSearch = x.value; + this.loadMoreTypes(); + }); + this.deviceGroupsSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.deviceGroupsSearch = x.value; + this.loadMoreGroups(); + }); } create(rawValue: DeviceCreateDto) { @@ -59,81 +83,64 @@ export class DeviceCreateService { }); } - loadMoreTypes(init = false) { - if (init) { - this.deviceTypesPage = 0; - this.deviceTypes.set([]); - } + loadMoreTypes() { + this.deviceTypesIsLoading.set(true); this.apiDeviceTypesService - .deviceTypeControllerGetAll(this.deviceTypesItemsPerPage, this.deviceTypesPage * this.deviceTypesItemsPerPage) + .deviceTypeControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceTypesSearch) .subscribe({ next: (deviceTypes) => { this.deviceTypesIsLoading.set(false); - const newDeviceTypes = [ - ...this.deviceTypes(), - ...deviceTypes, - ]; - this.deviceTypes.set(newDeviceTypes); - this.deviceTypesPage += 1; + this.deviceTypes.set(deviceTypes); }, error: () => { this.deviceTypesIsLoading.set(false); this.deviceTypes.set([]); - this.deviceTypesPage = 0; } }); } - loadMoreGroups(init = false) { - if (init) { - this.deviceGroupsPage = 0; - this.deviceGroups.set([]); - } + loadMoreGroups() { this.deviceGroupsIsLoading.set(true); this.apiDeviceGroupsService - .deviceGroupControllerGetAll(this.deviceGroupsItemsPerPage, this.deviceGroupsPage * this.deviceGroupsItemsPerPage) + .deviceGroupControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceGroupsSearch) .subscribe({ next: (deviceGroups) => { this.deviceGroupsIsLoading.set(false); - const newDeviceGroups = [ - ...this.deviceGroups(), - ...deviceGroups, - ]; - this.deviceGroups.set(newDeviceGroups); - this.deviceGroupsPage += 1; + this.deviceGroups.set(deviceGroups); }, error: () => { this.deviceGroupsIsLoading.set(false); this.deviceGroups.set([]); - this.deviceGroupsPage = 0; } }); } - loadMoreLocations(init = false) { - if (init) { - this.locationsPage = 0; - this.locations.set([]); - } + loadMoreLocations() { this.locationsIsLoading.set(true); this.apiLocationsService - .locationControllerGetAll(this.locationsItemsPerPage, this.locationsPage * this.locationsItemsPerPage) + .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.locationSearch) .subscribe({ next: (locations) => { this.locationsIsLoading.set(false); - const newlocations = [ - ...this.locations(), - ...locations, - ]; - this.locations.set(newlocations); - this.locationsPage += 1; + this.locations.set(locations); }, error: () => { this.locationsIsLoading.set(false); this.locations.set([]); - this.locationsPage = 0; } }); } + + onSearchLocation(value: string): void { + this.locationSearch$.next({propagate: true, value}); + } + + onSearchType(value: string): void { + this.deviceTypesSearch$.next({propagate: true, value}); + } + + onSearchGroup(value: string): void { + this.deviceGroupsSearch$.next({propagate: true, value}); + } } 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 468e73b..9df7f9d 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 @@ -47,21 +47,20 @@

Der Gerät wurde nicht gefunden!

Standort/Fahrzeug - @for (item of locations(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (locationsIsLoading()) { + } @else { + @for (item of locations(); track item) { + + } } - + @@ -69,20 +68,20 @@

Der Gerät wurde nicht gefunden!

Geräte-Typ - @for (item of deviceTypes(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (deviceTypesIsLoading()) { + } @else { + @for (item of deviceTypes(); track item) { + + } } - + @@ -90,20 +89,20 @@

Der Gerät wurde nicht gefunden!

Geräte-Gruppe - @for (item of deviceGroups(); track item) { - - } - - + nzShowSearch + nzServerSearch> @if (deviceGroupsIsLoading()) { + } @else { + @for (item of deviceGroups(); track item) { + + } } - + 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 65ffd6a..d2b41f0 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 @@ -195,15 +195,15 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { this.service.delete(); } - loadMoreTypes() { - this.service.loadMoreTypes(); + onSearchLocation(search: string) { + this.service.onSearchLocation(search); } - loadMoreGroups() { - this.service.loadMoreGroups(); + onSearchType(search: string) { + this.service.onSearchType(search); } - loadMoreLocations() { - this.service.loadMoreLocations(); + onSearchGroup(search: string) { + this.service.onSearchGroup(search); } } 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 8265dbd..e75097a 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 @@ -1,9 +1,9 @@ -import {Injectable, signal} from '@angular/core'; +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 {Subject} from 'rxjs'; +import {BehaviorSubject, debounceTime, filter, Subject} from 'rxjs'; import {DeviceDto} from '@backend/model/deviceDto'; import {DeviceTypeDto} from '@backend/model/deviceTypeDto'; import {DeviceUpdateDto} from '@backend/model/deviceUpdateDto'; @@ -11,6 +11,7 @@ import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; 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'; @Injectable({ providedIn: 'root' @@ -28,20 +29,20 @@ export class DeviceDetailService { updateLoadingSuccess = new Subject(); deleteLoadingSuccess = new Subject(); + deviceTypesSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + deviceTypesSearch = ''; deviceTypes = signal([]); deviceTypesIsLoading = signal(false); - private deviceTypesPage = 0; - private deviceTypesItemsPerPage = 10; + deviceGroupsSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + deviceGroupsSearch = ''; deviceGroups = signal([]); deviceGroupsIsLoading = signal(false); - private deviceGroupsPage = 0; - private deviceGroupsItemsPerPage = 10; + locationSearch$ = new BehaviorSubject<{ propagate: boolean; value: string }>({propagate: false, value: ''}); + locationSearch = ''; locations = signal([]); locationsIsLoading = signal(false); - private locationsPage = 0; - private locationsItemsPerPage = 10; constructor( private readonly apiService: DeviceService, @@ -49,14 +50,35 @@ export class DeviceDetailService { private readonly apiDeviceGroupsService: DeviceGroupService, private readonly apiLocationsService: LocationService, private readonly router: Router, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, + @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, ) { + this.locationSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.locationSearch = x.value; + this.loadMoreLocations(); + }); + this.deviceTypesSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.deviceTypesSearch = x.value; + this.loadMoreTypes(); + }); + this.deviceGroupsSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.deviceGroupsSearch = x.value; + this.loadMoreGroups(); + }); } load(id: number) { this.id = id; - this.deviceTypesPage = 0; - this.deviceGroupsPage = 0; - this.locationsPage = 0; + this.deviceTypes.set([]); this.deviceGroups.set([]); this.locations.set([]); @@ -76,9 +98,6 @@ export class DeviceDetailService { if (newEntity.location) { this.locations.set([newEntity.location]); } - this.loadMoreTypes(); - this.loadMoreGroups(); - this.loadMoreLocations(); }, error: (err: HttpErrorResponse) => { if (err.status === 404) { @@ -132,21 +151,15 @@ export class DeviceDetailService { loadMoreTypes() { this.deviceTypesIsLoading.set(true); this.apiDeviceTypesService - .deviceTypeControllerGetAll(this.deviceTypesItemsPerPage, this.deviceTypesPage * this.deviceTypesItemsPerPage) + .deviceTypeControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceTypesSearch) .subscribe({ next: (deviceTypes) => { this.deviceTypesIsLoading.set(false); - const newDeviceTypes = [ - ...this.deviceTypes(), - ...deviceTypes.filter(x => x.id != this.entity()?.typeId), - ]; - this.deviceTypes.set(newDeviceTypes); - this.deviceTypesPage += 1; + this.deviceTypes.set(deviceTypes); }, error: () => { this.deviceTypesIsLoading.set(false); this.deviceTypes.set([]); - this.deviceTypesPage = 0; } }); } @@ -154,21 +167,15 @@ export class DeviceDetailService { loadMoreGroups() { this.deviceGroupsIsLoading.set(true); this.apiDeviceGroupsService - .deviceGroupControllerGetAll(this.deviceGroupsItemsPerPage, this.deviceGroupsPage * this.deviceGroupsItemsPerPage) + .deviceGroupControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceGroupsSearch) .subscribe({ next: (deviceGroups) => { this.deviceGroupsIsLoading.set(false); - const newDeviceGroups = [ - ...this.deviceGroups(), - ...deviceGroups.filter(x => x.id != this.entity()?.groupId), - ]; - this.deviceGroups.set(newDeviceGroups); - this.deviceGroupsPage += 1; + this.deviceGroups.set(deviceGroups); }, error: () => { this.deviceGroupsIsLoading.set(false); this.deviceGroups.set([]); - this.deviceGroupsPage = 0; } }); } @@ -176,22 +183,28 @@ export class DeviceDetailService { loadMoreLocations() { this.locationsIsLoading.set(true); this.apiLocationsService - .locationControllerGetAll(this.locationsItemsPerPage, this.locationsPage * this.locationsItemsPerPage) + .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.locationSearch) .subscribe({ next: (locations) => { this.locationsIsLoading.set(false); - const newlocations = [ - ...this.locations(), - ...locations.filter(x => x.id != this.entity()?.locationId), - ]; - this.locations.set(newlocations); - this.locationsPage += 1; + this.locations.set(locations); }, error: () => { this.locationsIsLoading.set(false); this.locations.set([]); - this.locationsPage = 0; } }); } + + onSearchLocation(value: string): void { + this.locationSearch$.next({propagate: true, value}); + } + + onSearchType(value: string): void { + this.deviceTypesSearch$.next({propagate: true, value}); + } + + onSearchGroup(value: string): void { + this.deviceGroupsSearch$.next({propagate: true, value}); + } } From 26c09c13edb6f7bfce68627a1474cccac59550f7 Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Wed, 28 May 2025 08:31:07 +0200 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8Fopenapi=20refactorin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 4 +- .../group-create/group-create.service.ts | 2 +- .../group-detail/group-detail.service.ts | 24 ++++----- .../administration/groups/groups.service.ts | 5 +- .../users/user-create/user-create.service.ts | 2 +- .../users/user-detail/user-detail.service.ts | 18 +++---- .../administration/users/users.service.ts | 5 +- .../location-create.service.ts | 7 ++- .../location-detail.service.ts | 11 ++-- .../pages/base/locations/locations.service.ts | 12 +++-- .../device-group-create.service.ts | 2 +- .../device-group-detail.service.ts | 6 +-- .../device-groups/device-groups.service.ts | 12 +++-- .../device-type-create.service.ts | 2 +- .../device-type-detail.service.ts | 6 +-- .../device-types/device-types.service.ts | 10 +++- .../device-create/device-create.service.ts | 53 +++++++++++-------- .../device-detail/device-detail.service.ts | 21 +++++--- .../inventory/devices/devices.service.ts | 11 ++-- 19 files changed, 133 insertions(+), 80 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 8428ecd..2ed6266 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", - "generate:backend": "openapi-generator-cli generate -i ../openapi/backend.yml -g typescript-angular -o client/backend", + "generate:backend": "openapi-generator-cli generate -i ../openapi/backend.yml -g typescript-angular -o client/backend --additional-properties=useSingleRequestParameter=true", "lint": "ng lint" }, "private": true, @@ -50,4 +50,4 @@ "typescript": "~5.7.2", "typescript-eslint": "8.27.0" } -} \ No newline at end of file +} diff --git a/frontend/src/app/pages/administration/groups/group-create/group-create.service.ts b/frontend/src/app/pages/administration/groups/group-create/group-create.service.ts index 9726fdb..e3d7231 100644 --- a/frontend/src/app/pages/administration/groups/group-create/group-create.service.ts +++ b/frontend/src/app/pages/administration/groups/group-create/group-create.service.ts @@ -18,7 +18,7 @@ export class GroupCreateService { create(rawValue: GroupCreateDto): void { this.createLoading.set(true); - this.groupService.groupControllerCreateGroup(rawValue) + this.groupService.groupControllerCreateGroup({groupCreateDto: rawValue}) .subscribe({ next: (group) => { this.createLoading.set(false); diff --git a/frontend/src/app/pages/administration/groups/group-detail/group-detail.service.ts b/frontend/src/app/pages/administration/groups/group-detail/group-detail.service.ts index 6e6a773..a0c3856 100644 --- a/frontend/src/app/pages/administration/groups/group-detail/group-detail.service.ts +++ b/frontend/src/app/pages/administration/groups/group-detail/group-detail.service.ts @@ -40,10 +40,10 @@ export class GroupDetailService { this.usersTransferLoading.set(true); this.rolesTransferLoading.set(true); - this.groupService.groupControllerGetGroup(id) + this.groupService.groupControllerGetGroup({id}) .pipe( tap((group) => this.group.set(group)), - mergeMap(() => this.groupService.groupControllerGetMembers(id)), + mergeMap(() => this.groupService.groupControllerGetMembers({id})), tap((data) => this.users.set(data.users.map((user) => ({ user, member: data.members.some(x => x.id === user.id) @@ -82,7 +82,7 @@ export class GroupDetailService { const group = this.group(); if (group) { this.updateLoading.set(true); - this.groupService.groupControllerUpdateGroup(group.id, rawValue) + this.groupService.groupControllerUpdateGroup({id: group.id, groupUpdateDto: rawValue}) .subscribe({ next: (group) => { this.updateLoading.set(false); @@ -101,7 +101,7 @@ export class GroupDetailService { const group = this.group(); if (group) { this.deleteLoading.set(true); - this.groupService.groupControllerDeleteGroup(group.id) + this.groupService.groupControllerDeleteGroup({id: group.id}) .subscribe({ next: () => { this.deleteLoading.set(false); @@ -120,13 +120,13 @@ export class GroupDetailService { this.usersTransferLoading.set(true); from([users]).pipe( mergeMap((users) => - forkJoin(users.map((user) => add ? - this.groupService.groupControllerAddMemberToGroup(this.group()!.id, user) : - this.groupService.groupControllerRemoveMemberFromGroup(this.group()!.id, user))) + forkJoin(users.map((userId) => add ? + this.groupService.groupControllerAddMemberToGroup({id: this.group()!.id, userId}) : + this.groupService.groupControllerRemoveMemberFromGroup({id: this.group()!.id, userId}))) ), - mergeMap(() => this.groupService.groupControllerGetGroup(this.group()!.id)), + mergeMap(() => this.groupService.groupControllerGetGroup({id: this.group()!.id})), tap((group) => this.group.set(group)), - mergeMap(() => this.groupService.groupControllerGetMembers(this.group()!.id)), + mergeMap(() => this.groupService.groupControllerGetMembers({id: this.group()!.id})), tap((data) => this.users.set(data.users.map((user) => ({ user, member: data.members.some(x => x.id === user.id) @@ -148,10 +148,10 @@ export class GroupDetailService { from([roles]).pipe( mergeMap((roles) => forkJoin(roles.map((role) => add ? - this.groupService.groupControllerAddRoleToGroup(this.group()!.id, role) : - this.groupService.groupControllerRemoveRoleFromGroup(this.group()!.id, role))) + this.groupService.groupControllerAddRoleToGroup({id: this.group()!.id, role}) : + this.groupService.groupControllerRemoveRoleFromGroup({id: this.group()!.id, role}))) ), - mergeMap(() => this.groupService.groupControllerGetGroup(this.group()!.id)), + mergeMap(() => this.groupService.groupControllerGetGroup({id: this.group()!.id})), tap((group) => this.group.set(group)), mergeMap(() => this.groupService.groupControllerGetRoles()), tap((data) => this.roles.set(data.map((role) => ({ diff --git a/frontend/src/app/pages/administration/groups/groups.service.ts b/frontend/src/app/pages/administration/groups/groups.service.ts index 6aabf46..20c11c0 100644 --- a/frontend/src/app/pages/administration/groups/groups.service.ts +++ b/frontend/src/app/pages/administration/groups/groups.service.ts @@ -20,7 +20,10 @@ export class GroupsService { this.groupsLoading.set(true); this.groupService.groupControllerGetCount() .subscribe((count) => this.total.set(count.count)); - this.groupService.groupControllerGetGroups(this.itemsPerPage(), this.page() * this.itemsPerPage()) + this.groupService.groupControllerGetGroups({ + limit: this.itemsPerPage(), + offset: this.page() * this.itemsPerPage(), + }) .subscribe({ next: (groups) => { this.groups.set(groups); diff --git a/frontend/src/app/pages/administration/users/user-create/user-create.service.ts b/frontend/src/app/pages/administration/users/user-create/user-create.service.ts index 042861e..4393031 100644 --- a/frontend/src/app/pages/administration/users/user-create/user-create.service.ts +++ b/frontend/src/app/pages/administration/users/user-create/user-create.service.ts @@ -18,7 +18,7 @@ export class UserCreateService { create(rawValue: UserCreateDto) { this.createLoading.set(true); - this.userService.userControllerCreateUser(rawValue) + this.userService.userControllerCreateUser({userCreateDto: rawValue}) .subscribe({ next: (user) => { this.createLoading.set(false); diff --git a/frontend/src/app/pages/administration/users/user-detail/user-detail.service.ts b/frontend/src/app/pages/administration/users/user-detail/user-detail.service.ts index 46fb7b4..9fb9158 100644 --- a/frontend/src/app/pages/administration/users/user-detail/user-detail.service.ts +++ b/frontend/src/app/pages/administration/users/user-detail/user-detail.service.ts @@ -34,10 +34,10 @@ export class UserDetailService { load(id: string) { this.loading.set(true); - this.userService.userControllerGetUser(id) + this.userService.userControllerGetUser({id}) .pipe( tap((user) => this.user.set(user)), - mergeMap(() => this.userService.userControllerGetGroups(id)), + mergeMap(() => this.userService.userControllerGetGroups({id})), tap((data) => this.groups.set(data.groups.map((group) => ({ group, member: data.members.some(x => x.id === group.id) @@ -63,7 +63,7 @@ export class UserDetailService { const user = this.user(); if (user) { this.updateLoading.set(true); - this.userService.userControllerUpdateUser(user.id, rawValue) + this.userService.userControllerUpdateUser({id: user.id, userUpdateDto: rawValue}) .subscribe({ next: (user) => { this.updateLoading.set(false); @@ -82,13 +82,13 @@ export class UserDetailService { this.groupsTransferLoading.set(true); from([groups]).pipe( mergeMap((users) => - forkJoin(users.map((group) => add ? - this.userService.userControllerAddUserToGroup(this.user()!.id, group) : - this.userService.userControllerRemoveUserFromGroup(this.user()!.id, group))) + forkJoin(users.map((groupId) => add ? + this.userService.userControllerAddUserToGroup({id: this.user()!.id, groupId}) : + this.userService.userControllerRemoveUserFromGroup({id: this.user()!.id, groupId}))) ), - mergeMap(() => this.userService.userControllerGetUser(this.user()!.id)), + mergeMap(() => this.userService.userControllerGetUser({id: this.user()!.id})), tap((user) => this.user.set(user)), - mergeMap(() => this.userService.userControllerGetGroups(this.user()!.id)), + mergeMap(() => this.userService.userControllerGetGroups({id: this.user()!.id})), tap((data) => this.groups.set(data.groups.map((group) => ({ group, member: data.members.some(x => x.id === group.id) @@ -109,7 +109,7 @@ export class UserDetailService { const user = this.user(); if (user) { this.deleteLoading.set(true); - this.userService.userControllerDeleteUser(user.id) + this.userService.userControllerDeleteUser({id: user.id}) .subscribe({ next: () => { this.deleteLoading.set(false); diff --git a/frontend/src/app/pages/administration/users/users.service.ts b/frontend/src/app/pages/administration/users/users.service.ts index 818be30..dd97ea4 100644 --- a/frontend/src/app/pages/administration/users/users.service.ts +++ b/frontend/src/app/pages/administration/users/users.service.ts @@ -20,7 +20,10 @@ export class UsersService { this.usersLoading.set(true); this.userService.userControllerGetCount() .subscribe((count) => this.total.set(count.count)); - this.userService.userControllerGetUsers(this.itemsPerPage(), this.page() * this.itemsPerPage()) + this.userService.userControllerGetUsers({ + limit: this.itemsPerPage(), + offset: this.page() * this.itemsPerPage(), + }) .subscribe({ next: (users) => { this.users.set(users); diff --git a/frontend/src/app/pages/base/locations/location-create/location-create.service.ts b/frontend/src/app/pages/base/locations/location-create/location-create.service.ts index 66f551b..65e342d 100644 --- a/frontend/src/app/pages/base/locations/location-create/location-create.service.ts +++ b/frontend/src/app/pages/base/locations/location-create/location-create.service.ts @@ -37,7 +37,7 @@ export class LocationCreateService { create(rawValue: LocationCreateDto) { this.createLoading.set(true); - this.locationService.locationControllerCreate(rawValue) + this.locationService.locationControllerCreate({locationCreateDto: rawValue}) .subscribe({ next: (entity) => { this.createLoading.set(false); @@ -54,7 +54,10 @@ export class LocationCreateService { loadParents() { this.parentsIsLoading.set(true); this.locationService - .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.parentsSearch) + .locationControllerGetAll({ + searchTerm: this.parentsSearch, + limit: this.selectCount, + }) .subscribe({ next: (parents) => { this.parentsIsLoading.set(false); diff --git a/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts b/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts index 8fa01f6..5411409 100644 --- a/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts +++ b/frontend/src/app/pages/base/locations/location-detail/location-detail.service.ts @@ -46,7 +46,7 @@ export class LocationDetailService { load(id: number) { this.id = id; this.loading.set(true); - this.locationService.locationControllerGetOne(id) + this.locationService.locationControllerGetOne({id}) .subscribe({ next: (newEntity) => { this.location.set(newEntity); @@ -72,7 +72,7 @@ export class LocationDetailService { const entity = this.location(); if (entity) { this.updateLoading.set(true); - this.locationService.locationControllerUpdate(entity.id, rawValue) + this.locationService.locationControllerUpdate({id: entity.id, locationUpdateDto: rawValue}) .subscribe({ next: (newEntity) => { this.updateLoading.set(false); @@ -91,7 +91,7 @@ export class LocationDetailService { const entity = this.location(); if (entity) { this.deleteLoading.set(true); - this.locationService.locationControllerDelete(entity.id) + this.locationService.locationControllerDelete({id: entity.id}) .subscribe({ next: () => { this.deleteLoading.set(false); @@ -109,7 +109,10 @@ export class LocationDetailService { loadParents() { this.parentsIsLoading.set(true); this.locationService - .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.parentsSearch) + .locationControllerGetAll({ + searchTerm: this.parentsSearch, + limit: this.selectCount, + }) .subscribe({ next: (parents) => { this.parentsIsLoading.set(false); diff --git a/frontend/src/app/pages/base/locations/locations.service.ts b/frontend/src/app/pages/base/locations/locations.service.ts index 0c0ff3a..2daf251 100644 --- a/frontend/src/app/pages/base/locations/locations.service.ts +++ b/frontend/src/app/pages/base/locations/locations.service.ts @@ -16,7 +16,7 @@ export class LocationsService { locationsLoadError = signal(false); sortCol?: string; sortDir?: string; - searchTerm$ = new BehaviorSubject<{propagate: boolean, value: string}>({propagate: true, value: ''}); + searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); private searchTerm?: string; @@ -38,9 +38,15 @@ export class LocationsService { load() { this.locationsLoading.set(true); - this.locationService.locationControllerGetCount(this.searchTerm) + this.locationService.locationControllerGetCount({searchTerm: this.searchTerm}) .subscribe((count) => this.total.set(count.count)); - this.locationService.locationControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir, this.searchTerm) + this.locationService.locationControllerGetAll({ + limit: this.itemsPerPage, + offset: (this.page - 1) * this.itemsPerPage, + sortCol: this.sortCol, + sortDir: this.sortDir, + searchTerm: this.searchTerm + }) .subscribe({ next: (users) => { this.locations.set(users); diff --git a/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts b/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts index da97651..5f3df7d 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts +++ b/frontend/src/app/pages/inventory/device-groups/device-group-create/device-group-create.service.ts @@ -21,7 +21,7 @@ export class DeviceGroupCreateService { create(rawValue: DeviceGroupCreateDto) { this.createLoading.set(true); - this.apiService.deviceGroupControllerCreate(rawValue) + this.apiService.deviceGroupControllerCreate({deviceGroupCreateDto: rawValue}) .subscribe({ next: (entity) => { this.createLoading.set(false); 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 0e2bd67..912b548 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 @@ -31,7 +31,7 @@ export class DeviceGroupDetailService { load(id: number) { this.id = id; this.loading.set(true); - this.locationService.deviceGroupControllerGetOne(id) + this.locationService.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(entity.id, rawValue) + this.locationService.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(entity.id) + this.locationService.deviceGroupControllerDelete({id: entity.id}) .subscribe({ next: () => { this.deleteLoading.set(false); diff --git a/frontend/src/app/pages/inventory/device-groups/device-groups.service.ts b/frontend/src/app/pages/inventory/device-groups/device-groups.service.ts index fc3061a..ad13c3b 100644 --- a/frontend/src/app/pages/inventory/device-groups/device-groups.service.ts +++ b/frontend/src/app/pages/inventory/device-groups/device-groups.service.ts @@ -16,7 +16,7 @@ export class DeviceGroupsService { entitiesLoadError = signal(false); sortCol?: string; sortDir?: string; - searchTerm$ = new BehaviorSubject<{propagate: boolean, value: string}>({propagate: true, value: ''}); + searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); private searchTerm?: string; constructor(private readonly apiService: DeviceGroupService, @@ -36,9 +36,15 @@ export class DeviceGroupsService { load() { this.entitiesLoading.set(true); - this.apiService.deviceGroupControllerGetCount(this.searchTerm) + this.apiService.deviceGroupControllerGetCount({searchTerm: this.searchTerm}) .subscribe((count) => this.total.set(count.count)); - this.apiService.deviceGroupControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir, this.searchTerm) + this.apiService.deviceGroupControllerGetAll({ + limit: this.itemsPerPage, + offset: (this.page - 1) * this.itemsPerPage, + sortCol: this.sortCol, + sortDir: this.sortDir, + searchTerm: this.searchTerm + }) .subscribe({ next: (users) => { this.entities.set(users); diff --git a/frontend/src/app/pages/inventory/device-types/device-type-create/device-type-create.service.ts b/frontend/src/app/pages/inventory/device-types/device-type-create/device-type-create.service.ts index 6bef996..eb2043a 100644 --- a/frontend/src/app/pages/inventory/device-types/device-type-create/device-type-create.service.ts +++ b/frontend/src/app/pages/inventory/device-types/device-type-create/device-type-create.service.ts @@ -21,7 +21,7 @@ export class DeviceTypeCreateService { create(rawValue: DeviceTypeCreateDto) { this.createLoading.set(true); - this.apiService.deviceTypeControllerCreate(rawValue) + this.apiService.deviceTypeControllerCreate({deviceTypeCreateDto: rawValue}) .subscribe({ next: (entity) => { this.createLoading.set(false); diff --git a/frontend/src/app/pages/inventory/device-types/device-type-detail/device-type-detail.service.ts b/frontend/src/app/pages/inventory/device-types/device-type-detail/device-type-detail.service.ts index 40b4805..55ceb0d 100644 --- a/frontend/src/app/pages/inventory/device-types/device-type-detail/device-type-detail.service.ts +++ b/frontend/src/app/pages/inventory/device-types/device-type-detail/device-type-detail.service.ts @@ -31,7 +31,7 @@ export class DeviceTypeDetailService { load(id: number) { this.id = id; this.loading.set(true); - this.locationService.deviceTypeControllerGetOne(id) + this.locationService.deviceTypeControllerGetOne({id}) .subscribe({ next: (newEntity) => { this.entity.set(newEntity); @@ -53,7 +53,7 @@ export class DeviceTypeDetailService { const entity = this.entity(); if (entity) { this.updateLoading.set(true); - this.locationService.deviceTypeControllerUpdate(entity.id, rawValue) + this.locationService.deviceTypeControllerUpdate({id: entity.id, deviceTypeUpdateDto: rawValue}) .subscribe({ next: (newEntity) => { this.updateLoading.set(false); @@ -72,7 +72,7 @@ export class DeviceTypeDetailService { const entity = this.entity(); if (entity) { this.deleteLoading.set(true); - this.locationService.deviceTypeControllerDelete(entity.id) + this.locationService.deviceTypeControllerDelete({id: entity.id}) .subscribe({ next: () => { this.deleteLoading.set(false); diff --git a/frontend/src/app/pages/inventory/device-types/device-types.service.ts b/frontend/src/app/pages/inventory/device-types/device-types.service.ts index 4ad5e0b..82706f0 100644 --- a/frontend/src/app/pages/inventory/device-types/device-types.service.ts +++ b/frontend/src/app/pages/inventory/device-types/device-types.service.ts @@ -36,9 +36,15 @@ export class DeviceTypesService { load() { this.entitiesLoading.set(true); - this.apiService.deviceTypeControllerGetCount(this.searchTerm) + this.apiService.deviceTypeControllerGetCount({searchTerm: this.searchTerm}) .subscribe((count) => this.total.set(count.count)); - this.apiService.deviceTypeControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, this.sortCol, this.sortDir, this.searchTerm) + this.apiService.deviceTypeControllerGetAll({ + limit: this.itemsPerPage, + offset: (this.page - 1) * this.itemsPerPage, + sortCol: this.sortCol, + sortDir: this.sortDir, + searchTerm: this.searchTerm + }) .subscribe({ next: (users) => { this.entities.set(users); diff --git a/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts b/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts index ec830e2..fc5a029 100644 --- a/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts +++ b/frontend/src/app/pages/inventory/devices/device-create/device-create.service.ts @@ -45,31 +45,31 @@ export class DeviceCreateService { @Inject(SELECT_ITEMS_COUNT) private readonly selectCount: number, ) { this.locationSearch$.pipe( - filter(x => x.propagate), - debounceTime(time), - ).subscribe((x) => { - this.locationSearch = x.value; - this.loadMoreLocations(); - }); - this.deviceTypesSearch$.pipe( - filter(x => x.propagate), - debounceTime(time), - ).subscribe((x) => { - this.deviceTypesSearch = x.value; - this.loadMoreTypes(); - }); - this.deviceGroupsSearch$.pipe( - filter(x => x.propagate), - debounceTime(time), - ).subscribe((x) => { - this.deviceGroupsSearch = x.value; + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.locationSearch = x.value; + this.loadMoreLocations(); + }); + this.deviceTypesSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.deviceTypesSearch = x.value; + this.loadMoreTypes(); + }); + this.deviceGroupsSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.deviceGroupsSearch = x.value; this.loadMoreGroups(); }); } create(rawValue: DeviceCreateDto) { this.createLoading.set(true); - this.apiService.deviceControllerCreate(rawValue) + this.apiService.deviceControllerCreate({deviceCreateDto: rawValue}) .subscribe({ next: (entity) => { this.createLoading.set(false); @@ -87,7 +87,10 @@ export class DeviceCreateService { this.deviceTypesIsLoading.set(true); this.apiDeviceTypesService - .deviceTypeControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceTypesSearch) + .deviceTypeControllerGetAll({ + limit: this.selectCount, + searchTerm: this.deviceTypesSearch + }) .subscribe({ next: (deviceTypes) => { this.deviceTypesIsLoading.set(false); @@ -103,7 +106,10 @@ export class DeviceCreateService { loadMoreGroups() { this.deviceGroupsIsLoading.set(true); this.apiDeviceGroupsService - .deviceGroupControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceGroupsSearch) + .deviceGroupControllerGetAll({ + limit: this.selectCount, + searchTerm: this.deviceGroupsSearch, + }) .subscribe({ next: (deviceGroups) => { this.deviceGroupsIsLoading.set(false); @@ -119,7 +125,10 @@ export class DeviceCreateService { loadMoreLocations() { this.locationsIsLoading.set(true); this.apiLocationsService - .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.locationSearch) + .locationControllerGetAll({ + limit: this.selectCount, + searchTerm: this.locationSearch + }) .subscribe({ next: (locations) => { this.locationsIsLoading.set(false); 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 e75097a..bdb152c 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 @@ -83,7 +83,7 @@ export class DeviceDetailService { this.deviceGroups.set([]); this.locations.set([]); this.loading.set(true); - this.apiService.deviceControllerGetOne(id) + this.apiService.deviceControllerGetOne({id}) .subscribe({ next: (newEntity) => { this.entity.set(newEntity); @@ -114,7 +114,7 @@ export class DeviceDetailService { const entity = this.entity(); if (entity) { this.updateLoading.set(true); - this.apiService.deviceControllerUpdate(entity.id, rawValue) + this.apiService.deviceControllerUpdate({id: entity.id, deviceUpdateDto: rawValue}) .subscribe({ next: (newEntity) => { this.updateLoading.set(false); @@ -133,7 +133,7 @@ export class DeviceDetailService { const entity = this.entity(); if (entity) { this.deleteLoading.set(true); - this.apiService.deviceControllerDelete(entity.id) + this.apiService.deviceControllerDelete({id: entity.id}) .subscribe({ next: () => { this.deleteLoading.set(false); @@ -151,7 +151,10 @@ export class DeviceDetailService { loadMoreTypes() { this.deviceTypesIsLoading.set(true); this.apiDeviceTypesService - .deviceTypeControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceTypesSearch) + .deviceTypeControllerGetAll({ + limit: this.selectCount, + searchTerm: this.deviceTypesSearch, + }) .subscribe({ next: (deviceTypes) => { this.deviceTypesIsLoading.set(false); @@ -167,7 +170,10 @@ export class DeviceDetailService { loadMoreGroups() { this.deviceGroupsIsLoading.set(true); this.apiDeviceGroupsService - .deviceGroupControllerGetAll(this.selectCount, 0, undefined, undefined, this.deviceGroupsSearch) + .deviceGroupControllerGetAll({ + limit: this.selectCount, + searchTerm: this.deviceGroupsSearch, + }) .subscribe({ next: (deviceGroups) => { this.deviceGroupsIsLoading.set(false); @@ -183,7 +189,10 @@ export class DeviceDetailService { loadMoreLocations() { this.locationsIsLoading.set(true); this.apiLocationsService - .locationControllerGetAll(this.selectCount, 0, undefined, undefined, this.locationSearch) + .locationControllerGetAll({ + limit: this.selectCount, + searchTerm: this.locationSearch, + }) .subscribe({ next: (locations) => { this.locationsIsLoading.set(false); diff --git a/frontend/src/app/pages/inventory/devices/devices.service.ts b/frontend/src/app/pages/inventory/devices/devices.service.ts index 32fc2b9..9345700 100644 --- a/frontend/src/app/pages/inventory/devices/devices.service.ts +++ b/frontend/src/app/pages/inventory/devices/devices.service.ts @@ -37,10 +37,15 @@ export class DevicesService { load() { this.entitiesLoading.set(true); - this.apiService.deviceControllerGetCount(this.searchTerm) + this.apiService.deviceControllerGetCount({searchTerm: this.searchTerm}) .subscribe((count) => this.total.set(count.count)); - this.apiService.deviceControllerGetAll(this.itemsPerPage, (this.page - 1) * this.itemsPerPage, undefined, - undefined, undefined, this.sortCol, this.sortDir, this.searchTerm) + this.apiService.deviceControllerGetAll({ + limit: this.itemsPerPage, + offset: (this.page - 1) * this.itemsPerPage, + searchTerm: this.searchTerm, + sortCol: this.sortCol, + sortDir: this.sortDir, + }) .subscribe({ next: (entities) => { this.entities.set(entities); From 54111a179589278131911188c7a2d2281c364db2 Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Wed, 2 Jul 2025 21:23:18 +0200 Subject: [PATCH 04/16] =?UTF-8?q?WIP=20frontend=20=F0=9F=9A=A7=20backend?= =?UTF-8?q?=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 476 +++++++++++++++++- backend/package.json | 1 + backend/src/core/configuration.ts | 43 ++ backend/src/core/core.module.ts | 19 +- .../src/core/services/amqp.service.spec.ts | 18 + backend/src/core/services/amqp.service.ts | 189 +++++++ .../src/core/services/startup.service.spec.ts | 2 +- backend/src/core/services/startup.service.ts | 5 +- .../dto/minio-listener/bucket-event.dto.ts | 36 ++ .../services/storage/image.service.spec.ts | 18 + .../core/services/storage/image.service.ts | 76 +++ .../storage/minio-listener.service.spec.ts | 18 + .../storage/minio-listener.service.ts | 72 +++ .../{ => storage}/minio.service.spec.ts | 0 .../services/{ => storage}/minio.service.ts | 53 +- .../src/inventory/device/device-db.service.ts | 11 +- .../inventory/device/device-image.entity.ts | 11 + .../src/inventory/device/device.controller.ts | 16 + backend/src/inventory/device/device.entity.ts | 6 +- .../src/inventory/device/device.service.ts | 46 +- .../inventory/device/dto/device-image.dto.ts | 8 + .../src/inventory/device/dto/device.dto.ts | 6 + backend/src/inventory/inventory.module.ts | 3 + backend/src/shared/dto/filename.dto.ts | 10 + backend/src/shared/dto/upload-url.dto.ts | 47 ++ dev.env | 4 + docker-compose.dev.yml | 40 ++ frontend/src/app/app.configs.ts | 3 + .../device-detail.component.html | 455 +++++++++-------- .../device-detail/device-detail.component.ts | 61 ++- .../device-detail/device-detail.service.ts | 48 +- .../image-upload-area.component.html | 16 + .../image-upload-area.component.less | 0 .../image-upload-area.component.spec.ts | 23 + .../image-upload-area.component.ts | 27 + openapi/backend.yml | 41 ++ 36 files changed, 1669 insertions(+), 239 deletions(-) create mode 100644 backend/src/core/services/amqp.service.spec.ts create mode 100644 backend/src/core/services/amqp.service.ts create mode 100644 backend/src/core/services/storage/dto/minio-listener/bucket-event.dto.ts create mode 100644 backend/src/core/services/storage/image.service.spec.ts create mode 100644 backend/src/core/services/storage/image.service.ts create mode 100644 backend/src/core/services/storage/minio-listener.service.spec.ts create mode 100644 backend/src/core/services/storage/minio-listener.service.ts rename backend/src/core/services/{ => storage}/minio.service.spec.ts (100%) rename backend/src/core/services/{ => storage}/minio.service.ts (53%) create mode 100644 backend/src/inventory/device/device-image.entity.ts create mode 100644 backend/src/inventory/device/dto/device-image.dto.ts create mode 100644 backend/src/shared/dto/filename.dto.ts create mode 100644 backend/src/shared/dto/upload-url.dto.ts create mode 100644 frontend/src/app/shared/image-upload-area/image-upload-area.component.html create mode 100644 frontend/src/app/shared/image-upload-area/image-upload-area.component.less create mode 100644 frontend/src/app/shared/image-upload-area/image-upload-area.component.spec.ts create mode 100644 frontend/src/app/shared/image-upload-area/image-upload-area.component.ts 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/core/configuration.ts b/backend/src/core/configuration.ts index de10310..66fd7a5 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,6 +32,15 @@ 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), @@ -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..8f90513 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,11 @@ 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 { 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 +66,7 @@ import { DeviceEntity } from '../inventory/device/device.entity'; DeviceTypeEntity, DeviceGroupEntity, DeviceEntity, + DeviceImageEntity, ], extra: { connectionLimit: 10, @@ -71,6 +77,7 @@ import { DeviceEntity } from '../inventory/device/device.entity'; // migrationsRun: true, }), }), + InventoryModule, ], providers: [ StartupService, @@ -83,8 +90,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..8c2ba32 --- /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(); + }); +}); \ No newline at end of file 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/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..d579f46 --- /dev/null +++ b/backend/src/core/services/storage/image.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { BucketEventRecordS3Object } from './dto/minio-listener/bucket-event.dto'; +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]; + + constructor( + private readonly minioService: MinioService, + private readonly deivceService: DeviceService, + ) {} + + public async processDevice( + key: string, + bucketObject: BucketEventRecordS3Object, + ) { + 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) { + const size = 600; + 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..8b807b7 --- /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, bucketObject); + } + } + } +} 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/minio.service.ts b/backend/src/core/services/storage/minio.service.ts similarity index 53% rename from backend/src/core/services/minio.service.ts rename to backend/src/core/services/storage/minio.service.ts index bee5bb4..510c0ea 100644 --- a/backend/src/core/services/minio.service.ts +++ b/backend/src/core/services/storage/minio.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Client } from 'minio'; +import { Client, ItemBucketMetadata } from 'minio'; @Injectable() export class MinioService { @@ -8,7 +8,6 @@ export class MinioService { 'image/jpeg', 'image/png', 'image/tiff', - 'image/bmp', 'image/heic', ]; @@ -44,7 +43,7 @@ export class MinioService { }); } - async setupEnvironment() { + public async setupEnvironment() { this.logger.debug('Minio-Setup gestartet'); // Bucket erstellen @@ -55,4 +54,52 @@ export class MinioService { this.logger.debug('Minio-Setup abgeschlossen'); } + + public async generatePresignedPutUrl(file: string): Promise { + return this.client.presignedPutObject( + this.bucketName, + file, + this.uploadExpiry, + ); + } + + public async generatePresignedPostUrl( + file: string, + contentType: string, + fileSizeMb: number, + ): Promise<{ + postURL: string; + formData: { + [key: string]: any; + }; + }> { + 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, + ); + } } diff --git a/backend/src/inventory/device/device-db.service.ts b/backend/src/inventory/device/device-db.service.ts index c82a4c6..626bd08 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,8 @@ 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.images', 'i'); if (searchTerm) { query = this.searchQueryBuilder(query, searchTerm); @@ -94,6 +98,7 @@ export class DeviceDbService { .leftJoinAndSelect('d.group', 'dg') .leftJoinAndSelect('d.location', 'l') .leftJoinAndSelect('l.parent', 'lp') + .leftJoinAndSelect('d.images', 'i') .where('d.id = :id', { id }); return query.getOne(); @@ -112,4 +117,8 @@ export class DeviceDbService { const result = await this.repo.delete(id); return (result.affected ?? 0) > 0; } + + async addImage(device: DeviceEntity, imageId: string) { + await this.imageRepo.save({ device, id: imageId }); + } } 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..ab51e9c --- /dev/null +++ b/backend/src/inventory/device/device-image.entity.ts @@ -0,0 +1,11 @@ +import { Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { DeviceEntity } from './device.entity'; + +@Entity() +export class DeviceImageEntity { + @PrimaryColumn('uuid') + id: string; + + @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..f6b19ed 100644 --- a/backend/src/inventory/device/device.controller.ts +++ b/backend/src/inventory/device/device.controller.ts @@ -13,6 +13,8 @@ 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'; @Controller('device') export class DeviceController { @@ -96,4 +98,18 @@ 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); + } } diff --git a/backend/src/inventory/device/device.entity.ts b/backend/src/inventory/device/device.entity.ts index 221d289..2c34afe 100644 --- a/backend/src/inventory/device/device.entity.ts +++ b/backend/src/inventory/device/device.entity.ts @@ -1,7 +1,8 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, ManyToOne, OneToMany, 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 +77,7 @@ export class DeviceEntity { onUpdate: 'CASCADE', }) location?: LocationEntity; + + @OneToMany(() => DeviceImageEntity, (x) => x.device, { onDelete: 'CASCADE' }) + images: DeviceImageEntity[]; } diff --git a/backend/src/inventory/device/device.service.ts b/backend/src/inventory/device/device.service.ts index 80e328e..0193051 100644 --- a/backend/src/inventory/device/device.service.ts +++ b/backend/src/inventory/device/device.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, InternalServerErrorException, NotFoundException, @@ -9,10 +10,16 @@ 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'; @Injectable() export class DeviceService { - constructor(private readonly dbService: DeviceDbService) {} + constructor( + private readonly dbService: DeviceDbService, + private readonly minioService: MinioService, + ) {} public async findAll( offset?: number, @@ -76,4 +83,41 @@ export class DeviceService { } return plainToInstance(DeviceDto, entity); } + + public async getImageUploadUrl(id: number, contentType: string) { + const device = await this.dbService.findOne(id); + if (!device) { + throw new NotFoundException(); + } + + 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, { + 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); + } } 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..1bee3e6 --- /dev/null +++ b/backend/src/inventory/device/dto/device-image.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class DeviceImageDto { + @ApiProperty() + @Expose() + id: string; +} diff --git a/backend/src/inventory/device/dto/device.dto.ts b/backend/src/inventory/device/dto/device.dto.ts index 0e4cdb4..2685e64 100644 --- a/backend/src/inventory/device/dto/device.dto.ts +++ b/backend/src/inventory/device/dto/device.dto.ts @@ -13,6 +13,7 @@ import { MaxLength, } from 'class-validator'; import { LocationDto } from '../../../base/location/dto/location.dto'; +import { DeviceImageDto } from './device-image.dto'; export class DeviceDto { @ApiProperty() @@ -147,4 +148,9 @@ export class DeviceDto { @Expose() @Type(() => LocationDto) location?: LocationDto; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @Type(() => DeviceImageDto) + images?: DeviceImageDto[]; } diff --git a/backend/src/inventory/inventory.module.ts b/backend/src/inventory/inventory.module.ts index d2f9604..a8cd7b4 100644 --- a/backend/src/inventory/inventory.module.ts +++ b/backend/src/inventory/inventory.module.ts @@ -12,6 +12,7 @@ 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 { DeviceImageEntity } from './device/device-image.entity'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { DeviceEntity } from './device/device.entity'; DeviceTypeEntity, DeviceGroupEntity, DeviceEntity, + DeviceImageEntity, ]), ], controllers: [DeviceTypeController, DeviceGroupController, DeviceController], @@ -30,5 +32,6 @@ import { DeviceEntity } from './device/device.entity'; DeviceService, DeviceDbService, ], + exports: [DeviceService], }) export class InventoryModule {} diff --git a/backend/src/shared/dto/filename.dto.ts b/backend/src/shared/dto/filename.dto.ts new file mode 100644 index 0000000..5cfb76a --- /dev/null +++ b/backend/src/shared/dto/filename.dto.ts @@ -0,0 +1,10 @@ +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/upload-url.dto.ts b/backend/src/shared/dto/upload-url.dto.ts new file mode 100644 index 0000000..5c6d458 --- /dev/null +++ b/backend/src/shared/dto/upload-url.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Transform, 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: { [key: string]: any }; +} 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..5cb97ea 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,10 +20,22 @@ 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 command: server /data --console-address ":9001" networks: + - queue - storage keycloak: @@ -119,7 +131,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/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/devices/device-detail/device-detail.component.html b/frontend/src/app/pages/inventory/devices/device-detail/device-detail.component.html index 9df7f9d..df5c0bc 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 @@ -6,227 +6,240 @@

Der 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. + } + + +
+ + + + + + + + + + + +
+ + +
+
+
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..ea5545c 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, map, mergeMap, of, 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'; @@ -20,6 +20,9 @@ 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'; interface DeviceDetailForm { name: FormControl; @@ -54,6 +57,8 @@ interface DeviceDetailForm { NzInputNumberModule, NzSpinModule, NzDatePickerModule, + NzTabsModule, + ImageUploadAreaComponent, ], templateUrl: './device-detail.component.html', styleUrl: './device-detail.component.less' @@ -183,8 +188,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 +211,54 @@ 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(item.file.name, item); + } + + imageChanged(event: NzUploadChangeParam) { + // TODO Device neu laden und Bilder aktualisieren + + // Upload event handling, wenn sich etwas ändert + if (event.type === "success") { + let i = 0; + let pollInterval = 500; + interval(pollInterval) + .pipe( + mergeMap(async () => { + //await this.equipmentService.findOne(this.entity.id); + }), + map(x => { + i++; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filename = (event.file.originFileObj as any)?.filename ?? ''; + /*if (x.documents.find(y => y.key === filename) !== undefined) { + // this.entity.documents = x.documents; + const i = this.imgList.findIndex(y => y.name === event.file.name); + if (i !== -1) { + this.imgList = this.imgList.slice(i, -1); + } + return true; + }*/ + console.log(i); + return false; + }), + takeWhile(x => !x && i < 10 / (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"); + } + } } 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..dfb387e 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,15 @@ 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, + HttpHeaders, + HttpRequest, + HttpResponse +} from '@angular/common/http'; +import {BehaviorSubject, debounceTime, filter, mergeMap, of, Subject, switchMap} from 'rxjs'; import {DeviceDto} from '@backend/model/deviceDto'; import {DeviceTypeDto} from '@backend/model/deviceTypeDto'; import {DeviceUpdateDto} from '@backend/model/deviceUpdateDto'; @@ -12,6 +19,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' @@ -50,6 +58,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, ) { @@ -216,4 +225,39 @@ 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 => { + 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); + } + } + }); + } } 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..a82c051 --- /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/openapi/backend.yml b/openapi/backend.yml index 138e4ed..826d822 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -1048,6 +1048,37 @@ 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/location/count: get: description: Gibt die Anzahl aller Standorte zurück @@ -1716,6 +1747,16 @@ components: - $ref: '#/components/schemas/LocationDto' required: - state + UploadUrlDto: + type: object + properties: + postURL: + type: string + formData: + type: object + required: + - postURL + - formData LocationCreateDto: type: object properties: From fa39c817796cb4ee16c462d6becdb146d28b8d5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:09:53 +0000 Subject: [PATCH 05/16] Initial plan From c79c698c2bc2f9f6c7cd6ff04fd94f54b1a65293 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:25:02 +0000 Subject: [PATCH 06/16] Implement backend consumable and consumable-group entities, services, and controllers Co-authored-by: KirschbaumP <2657033+KirschbaumP@users.noreply.github.com> --- backend/src/base/location/location.entity.ts | 4 + .../consumable-group-db.service.ts | 77 +++++++++++++++ .../consumable-group.controller.ts | 96 ++++++++++++++++++ .../consumable-group.entity.ts | 16 +++ .../consumable-group.service.ts | 73 ++++++++++++++ .../dto/consumable-group-create.dto.ts | 6 ++ .../dto/consumable-group-get-query.dto.ts | 29 ++++++ .../dto/consumable-group-update.dto.ts | 6 ++ .../dto/consumable-group.dto.ts | 31 ++++++ .../consumable/consumable-db.service.ts | 93 ++++++++++++++++++ .../consumable/consumable.controller.ts | 98 +++++++++++++++++++ .../inventory/consumable/consumable.entity.ts | 36 +++++++ .../consumable/consumable.service.ts | 77 +++++++++++++++ .../consumable/dto/consumable-create.dto.ts | 8 ++ .../dto/consumable-get-query.dto.ts | 35 +++++++ .../consumable/dto/consumable-update.dto.ts | 6 ++ .../consumable/dto/consumable.dto.ts | 74 ++++++++++++++ backend/src/inventory/inventory.module.ts | 22 ++++- 18 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 backend/src/inventory/consumable-group/consumable-group-db.service.ts create mode 100644 backend/src/inventory/consumable-group/consumable-group.controller.ts create mode 100644 backend/src/inventory/consumable-group/consumable-group.entity.ts create mode 100644 backend/src/inventory/consumable-group/consumable-group.service.ts create mode 100644 backend/src/inventory/consumable-group/dto/consumable-group-create.dto.ts create mode 100644 backend/src/inventory/consumable-group/dto/consumable-group-get-query.dto.ts create mode 100644 backend/src/inventory/consumable-group/dto/consumable-group-update.dto.ts create mode 100644 backend/src/inventory/consumable-group/dto/consumable-group.dto.ts create mode 100644 backend/src/inventory/consumable/consumable-db.service.ts create mode 100644 backend/src/inventory/consumable/consumable.controller.ts create mode 100644 backend/src/inventory/consumable/consumable.entity.ts create mode 100644 backend/src/inventory/consumable/consumable.service.ts create mode 100644 backend/src/inventory/consumable/dto/consumable-create.dto.ts create mode 100644 backend/src/inventory/consumable/dto/consumable-get-query.dto.ts create mode 100644 backend/src/inventory/consumable/dto/consumable-update.dto.ts create mode 100644 backend/src/inventory/consumable/dto/consumable.dto.ts diff --git a/backend/src/base/location/location.entity.ts b/backend/src/base/location/location.entity.ts index 5708c2a..8007222 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 { ConsumableEntity } from '../../inventory/consumable/consumable.entity'; export enum LocationType { NONE = 0, // Keine Angabe @@ -57,4 +58,7 @@ export class LocationEntity { @OneToMany(() => DeviceEntity, (x) => x.location) devices: DeviceEntity[]; + + @OneToMany(() => ConsumableEntity, (x) => x.location) + consumables: ConsumableEntity[]; } 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..19ee493 --- /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; + } +} \ No newline at end of file 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..f49d7f8 --- /dev/null +++ b/backend/src/inventory/consumable-group/consumable-group.controller.ts @@ -0,0 +1,96 @@ +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.DeviceTypeView], + }) + 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.DeviceTypeView], + }) + 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.DeviceTypeView], + }) + 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.DeviceTypeManage], + }) + 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.DeviceTypeManage], + }) + 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.DeviceTypeManage], + }) + public async delete(@Param() params: IdNumberDto): Promise { + await this.service.delete(params.id); + } +} \ No newline at end of file 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..bf70a8e --- /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[]; +} \ No newline at end of file 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..79ffe5f --- /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); + } +} \ No newline at end of file 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..707f6f6 --- /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) {} \ No newline at end of file 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..bc57c40 --- /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; +} \ No newline at end of file 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..a935e19 --- /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, +) {} \ No newline at end of file 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..13d0b37 --- /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; +} \ No newline at end of file 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..ae01e20 --- /dev/null +++ b/backend/src/inventory/consumable/consumable-db.service.ts @@ -0,0 +1,93 @@ +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'; + +@Injectable() +export class ConsumableDbService { + constructor( + @InjectRepository(ConsumableEntity) + private readonly repo: 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, + locationId?: 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.location', 'l') + .leftJoinAndSelect('l.parent', 'lp'); + + if (searchTerm) { + query = this.searchQueryBuilder(query, searchTerm); + } + + if (groupId) { + query = query.andWhere('c.groupId = :groupId', { groupId }); + } + + if (locationId) { + query = query.andWhere('c.locationId = :locationId', { locationId }); + } + + 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.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; + } +} \ No newline at end of file diff --git a/backend/src/inventory/consumable/consumable.controller.ts b/backend/src/inventory/consumable/consumable.controller.ts new file mode 100644 index 0000000..bf8edef --- /dev/null +++ b/backend/src/inventory/consumable/consumable.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 { 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'; + +@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.DeviceView], + }) + 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.DeviceView], + }) + public async getAll( + @Query() pagination: PaginationDto, + @Query() querys: ConsumableGetQueryDto, + @Query() search: SearchDto, + ): Promise { + return this.service.findAll( + pagination.offset, + pagination.limit, + querys.groupId, + querys.locationId, + querys.sortCol, + querys.sortDir, + search.searchTerm, + ); + } + + @Endpoint(EndpointType.GET, { + path: ':id', + description: 'Gibt ein Verbrauchsgut zurück', + responseType: ConsumableDto, + notFound: true, + roles: [Role.DeviceView], + }) + 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.DeviceManage], + }) + 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.DeviceManage], + }) + 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.DeviceManage], + }) + public async delete(@Param() params: IdNumberDto): Promise { + await this.service.delete(params.id); + } +} \ No newline at end of file diff --git a/backend/src/inventory/consumable/consumable.entity.ts b/backend/src/inventory/consumable/consumable.entity.ts new file mode 100644 index 0000000..c51d0e4 --- /dev/null +++ b/backend/src/inventory/consumable/consumable.entity.ts @@ -0,0 +1,36 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ConsumableGroupEntity } from '../consumable-group/consumable-group.entity'; +import { LocationEntity } from '../../base/location/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({ type: 'int' }) + quantity: number; + @Column({ type: 'date', nullable: true }) + expirationDate?: Date; + + @Column({ nullable: true }) + groupId?: number; + @ManyToOne(() => ConsumableGroupEntity, (x) => x.consumables, { + nullable: true, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }) + group?: ConsumableGroupEntity; + + @Column({ nullable: true }) + locationId?: number; + @ManyToOne(() => LocationEntity, (x) => x.consumables, { + nullable: true, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }) + location?: LocationEntity; +} \ No newline at end of file diff --git a/backend/src/inventory/consumable/consumable.service.ts b/backend/src/inventory/consumable/consumable.service.ts new file mode 100644 index 0000000..2b7916c --- /dev/null +++ b/backend/src/inventory/consumable/consumable.service.ts @@ -0,0 +1,77 @@ +import { + 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'; + +@Injectable() +export class ConsumableService { + constructor(private readonly dbService: ConsumableDbService) {} + + public async findAll( + offset?: number, + limit?: number, + groupId?: number, + locationId?: number, + sortCol?: string, + sortDir?: 'ASC' | 'DESC', + searchTerm?: string, + ) { + const entities = await this.dbService.findAll( + offset, + limit, + groupId, + locationId, + 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); + } +} \ No newline at end of file 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..f4f24b0 --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { ConsumableDto } from './consumable.dto'; + +export class ConsumableCreateDto extends OmitType(ConsumableDto, [ + 'id', + 'group', + 'location', +] as const) {} \ No newline at end of file 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..1aa595e --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +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 }) + @IsOptional() + @IsInt() + @IsPositive() + locationId?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @IsIn([ + 'id', + 'name', + 'quantity', + 'expirationDate', + 'group.name', + 'location.name', + ]) + sortCol?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @IsIn(['ASC', 'DESC']) + sortDir?: 'ASC' | 'DESC'; +} \ No newline at end of file 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..dc0bf02 --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-update.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { ConsumableCreateDto } from './consumable-create.dto'; + +export class ConsumableUpdateDto extends PartialType( + ConsumableCreateDto, +) {} \ No newline at end of file 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..5a1202b --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable.dto.ts @@ -0,0 +1,74 @@ +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'; +import { ConsumableGroupDto } from '../../consumable-group/dto/consumable-group.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() + @Expose() + @IsDefined() + @IsInt() + @IsPositive() + quantity: number; + + @ApiProperty({ required: false, nullable: true, type: Date }) + @Expose() + @IsOptional() + @Type(() => Date) + @IsDate() + expirationDate?: Date; + + @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() + @IsPositive() + locationId?: number; + + @ApiProperty({ required: false, nullable: true }) + @Expose() + @Type(() => LocationDto) + location?: LocationDto; +} \ No newline at end of file diff --git a/backend/src/inventory/inventory.module.ts b/backend/src/inventory/inventory.module.ts index a8cd7b4..d2e6105 100644 --- a/backend/src/inventory/inventory.module.ts +++ b/backend/src/inventory/inventory.module.ts @@ -12,6 +12,14 @@ 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 { DeviceImageEntity } from './device/device-image.entity'; @Module({ @@ -20,10 +28,18 @@ import { DeviceImageEntity } from './device/device-image.entity'; DeviceTypeEntity, DeviceGroupEntity, DeviceEntity, + ConsumableGroupEntity, + ConsumableEntity, DeviceImageEntity, ]), ], - controllers: [DeviceTypeController, DeviceGroupController, DeviceController], + controllers: [ + DeviceTypeController, + DeviceGroupController, + DeviceController, + ConsumableGroupController, + ConsumableController, + ], providers: [ DeviceTypeService, DeviceTypeDbService, @@ -31,6 +47,10 @@ import { DeviceImageEntity } from './device/device-image.entity'; DeviceGroupDbService, DeviceService, DeviceDbService, + ConsumableGroupService, + ConsumableGroupDbService, + ConsumableService, + ConsumableDbService, ], exports: [DeviceService], }) From 49b19eec49b2caacaf13c34e8d85fbb6efbcf080 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:31:05 +0000 Subject: [PATCH 07/16] Add basic frontend structure for consumables feature Co-authored-by: KirschbaumP <2657033+KirschbaumP@users.noreply.github.com> --- frontend/src/app/app.component.html | 6 + .../device-group-create.component.html | 40 +++++++ .../device-group-create.component.less | 0 .../device-group-create.component.spec.ts | 23 ++++ .../device-group-create.component.ts | 57 ++++++++++ .../device-group-create.service.spec.ts | 16 +++ .../device-group-create.service.ts | 37 +++++++ .../device-group-detail.component.html | 51 +++++++++ .../device-group-detail.component.less | 0 .../device-group-detail.component.spec.ts | 23 ++++ .../device-group-detail.component.ts | 103 ++++++++++++++++++ .../device-group-detail.service.spec.ts | 16 +++ .../device-group-detail.service.ts | 89 +++++++++++++++ .../consumable-group-list.component.ts | 33 ++++++ .../device-group-list.component.html | 23 ++++ .../device-group-list.component.less | 0 .../device-group-list.component.spec.ts | 23 ++++ .../device-group-list.component.ts | 52 +++++++++ .../consumable-groups.component.html | 17 +++ .../consumable-groups.component.less | 0 .../consumable-groups.component.spec.ts | 23 ++++ .../consumable-groups.component.ts | 40 +++++++ .../consumable-groups.service.spec.ts | 16 +++ .../consumable-groups.service.ts | 79 ++++++++++++++ .../app/pages/inventory/inventory.routes.ts | 6 + .../app/shared/models/consumable.models.ts | 49 +++++++++ .../shared/services/consumable-api.service.ts | 76 +++++++++++++ .../services/consumable-group-api.service.ts | 68 ++++++++++++ 28 files changed, 966 insertions(+) create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.html create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.less create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.html create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.less create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.html create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.less create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.less create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts create mode 100644 frontend/src/app/shared/models/consumable.models.ts create mode 100644 frontend/src/app/shared/services/consumable-api.service.ts create mode 100644 frontend/src/app/shared/services/consumable-group-api.service.ts diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 785d051..241ca84 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -40,6 +40,12 @@

Manager

  • Geräte-Gruppen
  • +
  • + Verbrauchsgüter +
  • +
  • + Verbrauchsgüter-Gruppen +
  • diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.html new file mode 100644 index 0000000..8bf5c13 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.html @@ -0,0 +1,40 @@ +

    Geräte-Typ 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/device-group-create.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts new file mode 100644 index 0000000..6bd1b12 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeviceGroupCreateComponent } from './device-group-create.component'; + +describe('DeviceGroupCreateComponent', () => { + let component: DeviceGroupCreateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeviceGroupCreateComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeviceGroupCreateComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.ts new file mode 100644 index 0000000..f8234b5 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.ts @@ -0,0 +1,57 @@ +import {Component, OnDestroy, Signal} from '@angular/core'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {Subject, takeUntil} from 'rxjs'; +import {DeviceGroupCreateService} from './device-group-create.service'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import {NzButtonModule} from 'ng-zorro-antd/button'; +import {NzFormModule} from 'ng-zorro-antd/form'; +import {NzInputModule} from 'ng-zorro-antd/input'; + +interface DeviceGroupCreateForm { + name: FormControl; + notice?: FormControl; +} + +@Component({ + selector: 'ofs-device-group-create', + imports: [ReactiveFormsModule, NzButtonModule, NzFormModule, NzInputModule], + standalone: true, + templateUrl: './device-group-create.component.html', + styleUrl: './device-group-create.component.less' +}) +export class DeviceGroupCreateComponent 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: DeviceGroupCreateService, + 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/device-group-create.service.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.spec.ts new file mode 100644 index 0000000..4496f46 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { DeviceGroupCreateService } from './device-group-create.service'; + +describe('DeviceGroupCreateService', () => { + let service: DeviceGroupCreateService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DeviceGroupCreateService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.ts new file mode 100644 index 0000000..5f3df7d --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.ts @@ -0,0 +1,37 @@ +import {Injectable, signal} from '@angular/core'; +import {DeviceGroupService} from '@backend/api/deviceGroup.service'; +import {Router} from '@angular/router'; +import {Subject} from 'rxjs'; +import {DeviceGroupCreateDto} from '@backend/model/deviceGroupCreateDto'; +import {HttpErrorResponse} from '@angular/common/http'; + +@Injectable({ + providedIn: 'root' +}) +export class DeviceGroupCreateService { + createLoading = signal(false); + createLoadingError = new Subject(); + createLoadingSuccess = new Subject(); + + constructor( + private readonly apiService: DeviceGroupService, + private readonly router: Router, + ) { + } + + create(rawValue: DeviceGroupCreateDto) { + this.createLoading.set(true); + this.apiService.deviceGroupControllerCreate({deviceGroupCreateDto: rawValue}) + .subscribe({ + next: (entity) => { + this.createLoading.set(false); + this.createLoadingSuccess.next(); + this.router.navigate(['inventory', 'device-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/device-group-detail.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.html new file mode 100644 index 0000000..9007dc0 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.html @@ -0,0 +1,51 @@ +

    Geräte-Gruppe bearbeiten

    + +

    Der Geräte-Gruppe wurde nicht gefunden!

    +
    + + Geräte-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/device-group-detail.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts new file mode 100644 index 0000000..1691d7f --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeviceGroupDetailComponent } from './device-group-detail.component'; + +describe('DeviceGroupDetailComponent', () => { + let component: DeviceGroupDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeviceGroupDetailComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeviceGroupDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.ts new file mode 100644 index 0000000..8712259 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-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 {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import {DeviceGroupDetailService} from './device-group-detail.service'; + +interface DeviceGroupDetailForm { + name: FormControl; + notice?: FormControl; +} + +@Component({ + selector: 'ofs-device-group-detail', + imports: [NgIf, ReactiveFormsModule, NzButtonModule, NzFormModule, NzInputModule, NzCheckboxModule, NzPopconfirmModule, NzSelectModule, NzInputNumberModule + ], + standalone: true, + templateUrl: './device-group-detail.component.html', + styleUrl: './device-group-detail.component.less' +}) +export class DeviceGroupDetailComponent 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: DeviceGroupDetailService, + 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 Geräte-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('Geräte-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/device-group-detail.service.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.spec.ts new file mode 100644 index 0000000..d5f1c74 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { DeviceGroupDetailService } from './device-group-detail.service'; + +describe('DeviceGroupDetailService', () => { + let service: DeviceGroupDetailService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DeviceGroupDetailService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.ts new file mode 100644 index 0000000..912b548 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.ts @@ -0,0 +1,89 @@ +import {Injectable, signal} from '@angular/core'; +import {Subject} from 'rxjs'; +import {Router} from '@angular/router'; +import {HttpErrorResponse} from '@angular/common/http'; +import {DeviceGroupService} from '@backend/api/deviceGroup.service'; +import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; +import {DeviceGroupUpdateDto} from '@backend/model/deviceGroupUpdateDto'; + +@Injectable({ + providedIn: 'root' +}) +export class DeviceGroupDetailService { + 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 locationService: DeviceGroupService, + private readonly router: Router, + ) { + } + + load(id: number) { + this.id = id; + this.loading.set(true); + this.locationService.deviceGroupControllerGetOne({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.locationService.deviceGroupControllerUpdate({id: entity.id, deviceGroupUpdateDto: 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.locationService.deviceGroupControllerDelete({id: entity.id}) + .subscribe({ + next: () => { + this.deleteLoading.set(false); + this.deleteLoadingSuccess.next(); + this.router.navigate(['inventory', 'device-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.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.ts new file mode 100644 index 0000000..af2ac12 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.ts @@ -0,0 +1,33 @@ +import {Component} from '@angular/core'; +import {ConsumableGroupsService} from '../consumable-groups.service'; +import {NzTableModule} from 'ng-zorro-antd/table'; +import {CommonModule} from '@angular/common'; + +@Component({ + selector: 'ofs-consumable-group-list', + imports: [ + NzTableModule, + CommonModule + ], + standalone: true, + template: ` + + + + Name + Bemerkung + + + + + {{ entity.name }} + {{ entity.notice || '-' }} + + + + `, + styleUrls: [] +}) +export class ConsumableGroupListComponent { + constructor(public service: ConsumableGroupsService) {} +} \ No newline at end of file diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.html new file mode 100644 index 0000000..d7b347e --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-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/device-group-list.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.less new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.spec.ts new file mode 100644 index 0000000..4406af2 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeviceGroupListComponent } from './device-group-list.component'; + +describe('DeviceGroupListComponent', () => { + let component: DeviceGroupListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeviceGroupListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DeviceGroupListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts new file mode 100644 index 0000000..9b9331d --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts @@ -0,0 +1,52 @@ +import {Component, effect, Signal} from '@angular/core'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import { + NzTableModule, + NzTableQueryParams, +} from 'ng-zorro-antd/table'; +import {DeviceGroupsService} from '../device-groups.service'; +import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; +import {HasRoleDirective} from '../../../../core/auth/has-role.directive'; +import {NgForOf} from '@angular/common'; +import {NzButtonModule} from 'ng-zorro-antd/button'; +import {NzTypographyModule} from 'ng-zorro-antd/typography'; +import {RouterLink} from '@angular/router'; +import {NzIconModule} from 'ng-zorro-antd/icon'; + +@Component({ + selector: 'ofs-device-group-list', + imports: [NzTableModule, NgForOf, HasRoleDirective, NzButtonModule, RouterLink, NzTypographyModule, NzIconModule], + templateUrl: './device-group-list.component.html', + styleUrl: './device-group-list.component.less' +}) +export class DeviceGroupListComponent { + entities: Signal; + total: Signal; + entitiesLoading: Signal; + itemsPerPage: number; + page: number; + + constructor( + private readonly service: DeviceGroupsService, + 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-Gruppen.'); + } + }); + } + + 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..327e31a --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html @@ -0,0 +1,17 @@ +

    Verbrauchsgüter-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..1bc3dab --- /dev/null +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts @@ -0,0 +1,79 @@ +import {Inject, Injectable, signal} from '@angular/core'; +import {ConsumableGroupDto} from '../../../shared/models/consumable.models'; +import {ConsumableGroupApiService} from '../../../shared/services/consumable-group-api.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: ConsumableGroupApiService, + @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.getCount(this.searchTerm) + .subscribe((count) => this.total.set(count.count)); + this.apiService.getAll({ + 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.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/inventory.routes.ts b/frontend/src/app/pages/inventory/inventory.routes.ts index 7006193..742cfc6 100644 --- a/frontend/src/app/pages/inventory/inventory.routes.ts +++ b/frontend/src/app/pages/inventory/inventory.routes.ts @@ -9,6 +9,7 @@ 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'; export const ROUTES: Route[] = [ { @@ -56,4 +57,9 @@ export const ROUTES: Route[] = [ component: DeviceGroupDetailComponent, canActivate: [roleGuard(['device-group.manage'])], }, + { + path: 'consumable-groups', + component: ConsumableGroupsComponent, + canActivate: [roleGuard(['device-group.view'])], + }, ]; diff --git a/frontend/src/app/shared/models/consumable.models.ts b/frontend/src/app/shared/models/consumable.models.ts new file mode 100644 index 0000000..18fd850 --- /dev/null +++ b/frontend/src/app/shared/models/consumable.models.ts @@ -0,0 +1,49 @@ +export interface ConsumableGroupDto { + id: number; + name: string; + notice?: string; +} + +export interface ConsumableGroupCreateDto { + name: string; + notice?: string; +} + +export interface ConsumableGroupUpdateDto { + name?: string; + notice?: string; +} + +export interface ConsumableDto { + id: number; + name?: string; + notice?: string; + quantity: number; + expirationDate?: Date; + groupId?: number; + group?: ConsumableGroupDto; + locationId?: number; + location?: any; // LocationDto - will be imported later +} + +export interface ConsumableCreateDto { + name?: string; + notice?: string; + quantity: number; + expirationDate?: Date; + groupId?: number; + locationId?: number; +} + +export interface ConsumableUpdateDto { + name?: string; + notice?: string; + quantity?: number; + expirationDate?: Date; + groupId?: number; + locationId?: number; +} + +export interface CountDto { + count: number; +} \ No newline at end of file diff --git a/frontend/src/app/shared/services/consumable-api.service.ts b/frontend/src/app/shared/services/consumable-api.service.ts new file mode 100644 index 0000000..77cc2ef --- /dev/null +++ b/frontend/src/app/shared/services/consumable-api.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { + ConsumableDto, + ConsumableCreateDto, + ConsumableUpdateDto, + CountDto +} from '../models/consumable.models'; + +@Injectable({ + providedIn: 'root' +}) +export class ConsumableApiService { + private readonly basePath = '/api/consumable'; + + 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; + groupId?: number; + locationId?: 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?.groupId) { + params = params.set('groupId', options.groupId.toString()); + } + if (options?.locationId) { + params = params.set('locationId', options.locationId.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: ConsumableCreateDto): Observable { + return this.http.post(this.basePath, dto); + } + + update(id: number, dto: ConsumableUpdateDto): 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/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 From 3155acfe1072fad74c99adfe0abdee22c01a1f50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:34:48 +0000 Subject: [PATCH 08/16] Complete OpenAPI spec update and regenerate backend client for consumables Co-authored-by: KirschbaumP <2657033+KirschbaumP@users.noreply.github.com> --- .../consumable-groups.service.ts | 10 +- openapi/backend.yml | 467 ++++++++++++++++++ 2 files changed, 472 insertions(+), 5 deletions(-) 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 index 1bc3dab..e7cfb0d 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts @@ -1,6 +1,6 @@ import {Inject, Injectable, signal} from '@angular/core'; -import {ConsumableGroupDto} from '../../../shared/models/consumable.models'; -import {ConsumableGroupApiService} from '../../../shared/services/consumable-group-api.service'; +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'; @@ -19,7 +19,7 @@ export class ConsumableGroupsService { searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); private searchTerm?: string; - constructor(private readonly apiService: ConsumableGroupApiService, + constructor(private readonly apiService: ConsumableGroupService, @Inject(SEARCH_DEBOUNCE_TIME) time: number, ) { this.searchTerm$ @@ -36,9 +36,9 @@ export class ConsumableGroupsService { load() { this.entitiesLoading.set(true); - this.apiService.getCount(this.searchTerm) + this.apiService.consumableGroupControllerGetCount({searchTerm: this.searchTerm}) .subscribe((count) => this.total.set(count.count)); - this.apiService.getAll({ + this.apiService.consumableGroupControllerGetAll({ limit: this.itemsPerPage, offset: (this.page - 1) * this.itemsPerPage, sortCol: this.sortCol, diff --git a/openapi/backend.yml b/openapi/backend.yml index 826d822..838cfb6 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -1253,6 +1253,364 @@ paths: summary: '' tags: - Location + /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 + - 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: + type: number + - name: locationId + 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 + - 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 info: title: OFS-Manager description: '' @@ -1519,6 +1877,115 @@ components: nullable: true required: - name + 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 + required: + - name + ConsumableDto: + type: object + properties: + id: + type: number + name: + type: string + nullable: true + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + type: string + format: date + nullable: true + groupId: + type: number + nullable: true + group: + $ref: '#/components/schemas/ConsumableGroupDto' + nullable: true + locationId: + type: number + nullable: true + location: + $ref: '#/components/schemas/LocationDto' + nullable: true + required: + - id + - quantity + ConsumableCreateDto: + type: object + properties: + name: + type: string + nullable: true + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + type: string + format: date + nullable: true + groupId: + type: number + nullable: true + locationId: + type: number + nullable: true + required: + - quantity + ConsumableUpdateDto: + type: object + properties: + name: + type: string + nullable: true + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + type: string + format: date + nullable: true + groupId: + type: number + nullable: true + locationId: + type: number + nullable: true LocationDto: type: object properties: From de8b559704a8737a5bbb972328701761581b66e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:37:25 +0000 Subject: [PATCH 09/16] Add backend tests and complete consumables feature implementation Co-authored-by: KirschbaumP <2657033+KirschbaumP@users.noreply.github.com> --- .../consumable-group.service.spec.ts | 68 ++++++++++++++++++ .../consumable/consumable.service.spec.ts | 72 +++++++++++++++++++ frontend/angular.json | 4 +- 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 backend/src/inventory/consumable-group/consumable-group.service.spec.ts create mode 100644 backend/src/inventory/consumable/consumable.service.spec.ts 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..552357b --- /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); + }); +}); \ No newline at end of file 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..00ce24c --- /dev/null +++ b/backend/src/inventory/consumable/consumable.service.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConsumableService } from './consumable.service'; +import { ConsumableDbService } from './consumable-db.service'; +import { ConsumableCreateDto } from './dto/consumable-create.dto'; + +describe('ConsumableService', () => { + let service: ConsumableService; + let dbService: ConsumableDbService; + + 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: [ + ConsumableService, + { + provide: ConsumableDbService, + useValue: mockDbService, + }, + ], + }).compile(); + + service = module.get(ConsumableService); + dbService = module.get(ConsumableDbService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should create a consumable', async () => { + const createDto: ConsumableCreateDto = { + name: 'Test Consumable', + notice: 'Test notice', + quantity: 10, + expirationDate: new Date('2025-12-31'), + groupId: 1, + locationId: 1, + }; + + 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 consumables', async () => { + const mockCount = { count: 5 }; + mockDbService.getCount.mockResolvedValue(5); + + const result = await service.getCount(); + + expect(result).toEqual(mockCount); + expect(dbService.getCount).toHaveBeenCalledWith(undefined); + }); +}); \ No newline at end of file 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", From eea8bde13370db4cbaacc5437912fe8fb821f926 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 19:02:07 +0000 Subject: [PATCH 10/16] Convert consumable location relationship to many-to-many Co-authored-by: KirschbaumP <2657033+KirschbaumP@users.noreply.github.com> --- backend/src/base/location/location.entity.ts | 3 +- .../consumable/consumable-db.service.ts | 10 +-- .../consumable/consumable.controller.ts | 2 +- .../inventory/consumable/consumable.entity.ts | 13 ++-- .../consumable/consumable.service.spec.ts | 55 ++++++++++++++- .../consumable/consumable.service.ts | 68 +++++++++++++++++-- .../consumable/dto/consumable-create.dto.ts | 2 +- .../dto/consumable-get-query.dto.ts | 16 +++-- .../consumable/dto/consumable.dto.ts | 10 +-- backend/src/inventory/inventory.module.ts | 2 + 10 files changed, 146 insertions(+), 35 deletions(-) diff --git a/backend/src/base/location/location.entity.ts b/backend/src/base/location/location.entity.ts index 8007222..3e71991 100644 --- a/backend/src/base/location/location.entity.ts +++ b/backend/src/base/location/location.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, + ManyToMany, OneToMany, PrimaryGeneratedColumn, Tree, @@ -59,6 +60,6 @@ export class LocationEntity { @OneToMany(() => DeviceEntity, (x) => x.location) devices: DeviceEntity[]; - @OneToMany(() => ConsumableEntity, (x) => x.location) + @ManyToMany(() => ConsumableEntity, (consumable) => consumable.locations) consumables: ConsumableEntity[]; } diff --git a/backend/src/inventory/consumable/consumable-db.service.ts b/backend/src/inventory/consumable/consumable-db.service.ts index ae01e20..e09e53c 100644 --- a/backend/src/inventory/consumable/consumable-db.service.ts +++ b/backend/src/inventory/consumable/consumable-db.service.ts @@ -32,7 +32,7 @@ export class ConsumableDbService { offset?: number, limit?: number, groupId?: number, - locationId?: number, + locationIds?: number[], sortCol?: string, sortDir?: 'ASC' | 'DESC', searchTerm?: string, @@ -42,7 +42,7 @@ export class ConsumableDbService { .limit(limit ?? 100) .offset(offset ?? 0) .leftJoinAndSelect('c.group', 'cg') - .leftJoinAndSelect('c.location', 'l') + .leftJoinAndSelect('c.locations', 'l') .leftJoinAndSelect('l.parent', 'lp'); if (searchTerm) { @@ -53,8 +53,8 @@ export class ConsumableDbService { query = query.andWhere('c.groupId = :groupId', { groupId }); } - if (locationId) { - query = query.andWhere('c.locationId = :locationId', { locationId }); + if (locationIds && locationIds.length > 0) { + query = query.andWhere('l.id IN (:...locationIds)', { locationIds }); } if (sortCol) { @@ -71,7 +71,7 @@ export class ConsumableDbService { .createQueryBuilder('c') .where('c.id = :id', { id }) .leftJoinAndSelect('c.group', 'cg') - .leftJoinAndSelect('c.location', 'l') + .leftJoinAndSelect('c.locations', 'l') .leftJoinAndSelect('l.parent', 'lp'); return query.getOne(); diff --git a/backend/src/inventory/consumable/consumable.controller.ts b/backend/src/inventory/consumable/consumable.controller.ts index bf8edef..5990f36 100644 --- a/backend/src/inventory/consumable/consumable.controller.ts +++ b/backend/src/inventory/consumable/consumable.controller.ts @@ -43,7 +43,7 @@ export class ConsumableController { pagination.offset, pagination.limit, querys.groupId, - querys.locationId, + querys.locationIds, querys.sortCol, querys.sortDir, search.searchTerm, diff --git a/backend/src/inventory/consumable/consumable.entity.ts b/backend/src/inventory/consumable/consumable.entity.ts index c51d0e4..4358189 100644 --- a/backend/src/inventory/consumable/consumable.entity.ts +++ b/backend/src/inventory/consumable/consumable.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { ConsumableGroupEntity } from '../consumable-group/consumable-group.entity'; import { LocationEntity } from '../../base/location/location.entity'; @@ -25,12 +25,7 @@ export class ConsumableEntity { }) group?: ConsumableGroupEntity; - @Column({ nullable: true }) - locationId?: number; - @ManyToOne(() => LocationEntity, (x) => x.consumables, { - nullable: true, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }) - location?: LocationEntity; + @ManyToMany(() => LocationEntity, (location) => location.consumables) + @JoinTable() + locations?: LocationEntity[]; } \ No newline at end of file diff --git a/backend/src/inventory/consumable/consumable.service.spec.ts b/backend/src/inventory/consumable/consumable.service.spec.ts index 00ce24c..ad5bdac 100644 --- a/backend/src/inventory/consumable/consumable.service.spec.ts +++ b/backend/src/inventory/consumable/consumable.service.spec.ts @@ -1,11 +1,17 @@ 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'; describe('ConsumableService', () => { let service: ConsumableService; let dbService: ConsumableDbService; + let consumableRepo: Repository; + let locationRepo: Repository; const mockDbService = { findAll: jest.fn(), @@ -16,6 +22,22 @@ describe('ConsumableService', () => { getCount: jest.fn(), }; + const mockConsumableRepo = { + createQueryBuilder: jest.fn(() => ({ + relation: jest.fn(() => ({ + of: jest.fn(() => ({ + add: jest.fn(), + remove: jest.fn(), + loadMany: jest.fn(), + })), + })), + })), + }; + + const mockLocationRepo = { + findByIds: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -24,11 +46,21 @@ describe('ConsumableService', () => { provide: ConsumableDbService, useValue: mockDbService, }, + { + provide: getRepositoryToken(ConsumableEntity), + useValue: mockConsumableRepo, + }, + { + provide: getRepositoryToken(LocationEntity), + useValue: mockLocationRepo, + }, ], }).compile(); service = module.get(ConsumableService); dbService = module.get(ConsumableDbService); + consumableRepo = module.get>(getRepositoryToken(ConsumableEntity)); + locationRepo = module.get>(getRepositoryToken(LocationEntity)); }); it('should be defined', () => { @@ -42,22 +74,39 @@ describe('ConsumableService', () => { quantity: 10, expirationDate: new Date('2025-12-31'), groupId: 1, - locationId: 1, + locationIds: [1, 2], }; const mockEntity = { id: 1, - ...createDto, + name: 'Test Consumable', + notice: 'Test notice', + quantity: 10, + expirationDate: new Date('2025-12-31'), + groupId: 1, }; + const mockLocations = [ + { id: 1, name: 'Location 1' }, + { id: 2, name: 'Location 2' }, + ]; + mockDbService.create.mockResolvedValue(mockEntity); mockDbService.findOne.mockResolvedValue(mockEntity); + mockLocationRepo.findByIds.mockResolvedValue(mockLocations); const result = await service.create(createDto); expect(result).toEqual(mockEntity); - expect(dbService.create).toHaveBeenCalledWith(createDto); + expect(dbService.create).toHaveBeenCalledWith({ + name: 'Test Consumable', + notice: 'Test notice', + quantity: 10, + expirationDate: new Date('2025-12-31'), + groupId: 1, + }); expect(dbService.findOne).toHaveBeenCalledWith(1); + expect(locationRepo.findByIds).toHaveBeenCalledWith([1, 2]); }); it('should get count of consumables', async () => { diff --git a/backend/src/inventory/consumable/consumable.service.ts b/backend/src/inventory/consumable/consumable.service.ts index 2b7916c..68f662a 100644 --- a/backend/src/inventory/consumable/consumable.service.ts +++ b/backend/src/inventory/consumable/consumable.service.ts @@ -3,8 +3,12 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { plainToInstance } from 'class-transformer'; import { CountDto } from '../../shared/dto/count.dto'; +import { LocationEntity } from '../../base/location/location.entity'; +import { ConsumableEntity } from './consumable.entity'; import { ConsumableDbService } from './consumable-db.service'; import { ConsumableDto } from './dto/consumable.dto'; import { ConsumableCreateDto } from './dto/consumable-create.dto'; @@ -12,13 +16,19 @@ import { ConsumableUpdateDto } from './dto/consumable-update.dto'; @Injectable() export class ConsumableService { - constructor(private readonly dbService: ConsumableDbService) {} + constructor( + private readonly dbService: ConsumableDbService, + @InjectRepository(ConsumableEntity) + private readonly repo: Repository, + @InjectRepository(LocationEntity) + private readonly locationRepo: Repository, + ) {} public async findAll( offset?: number, limit?: number, groupId?: number, - locationId?: number, + locationIds?: number[], sortCol?: string, sortDir?: 'ASC' | 'DESC', searchTerm?: string, @@ -27,7 +37,7 @@ export class ConsumableService { offset, limit, groupId, - locationId, + locationIds, sortCol, sortDir, searchTerm, @@ -55,7 +65,24 @@ export class ConsumableService { } public async create(body: ConsumableCreateDto) { - const newEntity = await this.dbService.create(body); + // Extract locationIds from the body + const { locationIds, ...consumableData } = body; + + // Create the consumable entity first + const newEntity = await this.dbService.create(consumableData); + + // Handle location associations if provided + if (locationIds && locationIds.length > 0) { + const locations = await this.locationRepo.findByIds(locationIds); + if (locations.length > 0) { + await this.repo + .createQueryBuilder() + .relation(ConsumableEntity, 'locations') + .of(newEntity.id) + .add(locations.map(l => l.id)); + } + } + const entity = await this.dbService.findOne(newEntity.id); if (!entity) { throw new InternalServerErrorException(); @@ -64,10 +91,41 @@ export class ConsumableService { } public async update(id: number, body: ConsumableUpdateDto) { - if (!(await this.dbService.update(id, body))) { + // Extract locationIds from the body + const { locationIds, ...consumableData } = body; + + // Update the consumable entity first + if (!(await this.dbService.update(id, consumableData))) { throw new NotFoundException(); } + // Handle location associations if provided + if (locationIds !== undefined) { + // First, remove all existing location associations + await this.repo + .createQueryBuilder() + .relation(ConsumableEntity, 'locations') + .of(id) + .remove(await this.repo + .createQueryBuilder('c') + .relation('locations') + .of(id) + .loadMany() + ); + + // Then add new associations if any + if (locationIds.length > 0) { + const locations = await this.locationRepo.findByIds(locationIds); + if (locations.length > 0) { + await this.repo + .createQueryBuilder() + .relation(ConsumableEntity, 'locations') + .of(id) + .add(locations.map(l => l.id)); + } + } + } + const entity = await this.dbService.findOne(id); if (!entity) { throw new NotFoundException(); diff --git a/backend/src/inventory/consumable/dto/consumable-create.dto.ts b/backend/src/inventory/consumable/dto/consumable-create.dto.ts index f4f24b0..ca2d0e6 100644 --- a/backend/src/inventory/consumable/dto/consumable-create.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable-create.dto.ts @@ -4,5 +4,5 @@ import { ConsumableDto } from './consumable.dto'; export class ConsumableCreateDto extends OmitType(ConsumableDto, [ 'id', 'group', - 'location', + 'locations', ] as const) {} \ No newline at end of file diff --git a/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts b/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts index 1aa595e..685215d 100644 --- a/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsIn, IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; export class ConsumableGetQueryDto { @@ -8,11 +9,17 @@ export class ConsumableGetQueryDto { @IsPositive() groupId?: number; - @ApiProperty({ required: false, nullable: true }) + @ApiProperty({ required: false, nullable: true, type: [Number] }) @IsOptional() - @IsInt() - @IsPositive() - locationId?: number; + @Transform(({ value }) => { + if (typeof value === 'string') { + return value.split(',').map(id => parseInt(id, 10)); + } + return value; + }) + @IsInt({ each: true }) + @IsPositive({ each: true }) + locationIds?: number[]; @ApiProperty({ required: false }) @IsOptional() @@ -23,7 +30,6 @@ export class ConsumableGetQueryDto { 'quantity', 'expirationDate', 'group.name', - 'location.name', ]) sortCol?: string; diff --git a/backend/src/inventory/consumable/dto/consumable.dto.ts b/backend/src/inventory/consumable/dto/consumable.dto.ts index 5a1202b..7398c66 100644 --- a/backend/src/inventory/consumable/dto/consumable.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable.dto.ts @@ -63,12 +63,12 @@ export class ConsumableDto { @ApiProperty({ required: false, nullable: true }) @Expose() @IsOptional() - @IsInt() - @IsPositive() - locationId?: number; + @IsInt({ each: true }) + @IsPositive({ each: true }) + locationIds?: number[]; - @ApiProperty({ required: false, nullable: true }) + @ApiProperty({ required: false, nullable: true, type: [LocationDto] }) @Expose() @Type(() => LocationDto) - location?: LocationDto; + locations?: LocationDto[]; } \ No newline at end of file diff --git a/backend/src/inventory/inventory.module.ts b/backend/src/inventory/inventory.module.ts index d2e6105..12bf8ca 100644 --- a/backend/src/inventory/inventory.module.ts +++ b/backend/src/inventory/inventory.module.ts @@ -20,6 +20,7 @@ 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 { DeviceImageEntity } from './device/device-image.entity'; @Module({ @@ -30,6 +31,7 @@ import { DeviceImageEntity } from './device/device-image.entity'; DeviceEntity, ConsumableGroupEntity, ConsumableEntity, + LocationEntity, DeviceImageEntity, ]), ], From 589a3cb0994c02a39c3bb2355a32ca5a275c8cc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 19:48:41 +0000 Subject: [PATCH 11/16] Implement many-to-many location relationships, proper roles, and frontend consumables Co-authored-by: KirschbaumP <2657033+KirschbaumP@users.noreply.github.com> --- backend/src/core/auth/role/role.ts | 22 +++++ .../consumable-group.controller.ts | 12 +-- .../consumable/consumable.controller.ts | 40 ++++++-- .../consumable/consumable.service.ts | 48 ++++++++++ .../consumable-list.component.ts | 46 ++++++++++ .../consumables/consumables.component.html | 1 + .../consumables/consumables.component.less | 1 + .../consumables/consumables.component.ts | 20 ++++ .../consumables/consumables.service.ts | 84 +++++++++++++++++ .../app/pages/inventory/inventory.routes.ts | 8 +- .../app/shared/models/consumable.models.ts | 49 ---------- .../shared/services/consumable-api.service.ts | 76 ---------------- openapi/backend.yml | 91 +++++++++++++++++-- 13 files changed, 350 insertions(+), 148 deletions(-) create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumables.component.html create mode 100644 frontend/src/app/pages/inventory/consumables/consumables.component.less create mode 100644 frontend/src/app/pages/inventory/consumables/consumables.component.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumables.service.ts delete mode 100644 frontend/src/app/shared/models/consumable.models.ts delete mode 100644 frontend/src/app/shared/services/consumable-api.service.ts 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/inventory/consumable-group/consumable-group.controller.ts b/backend/src/inventory/consumable-group/consumable-group.controller.ts index f49d7f8..9fb3a6d 100644 --- a/backend/src/inventory/consumable-group/consumable-group.controller.ts +++ b/backend/src/inventory/consumable-group/consumable-group.controller.ts @@ -22,7 +22,7 @@ export class ConsumableGroupController { path: 'count', description: 'Gibt die Anzahl aller Verbrauchsgüter-Gruppen zurück', responseType: CountDto, - roles: [Role.DeviceTypeView], + roles: [Role.ConsumableGroupView], }) public getCount(@Query() search: SearchDto): Promise { return this.service.getCount(search.searchTerm); @@ -32,7 +32,7 @@ export class ConsumableGroupController { path: '', description: 'Gibt alle Verbrauchsgüter-Gruppen zurück', responseType: [ConsumableGroupDto], - roles: [Role.DeviceTypeView], + roles: [Role.ConsumableGroupView], }) public async getAll( @Query() pagination: PaginationDto, @@ -53,7 +53,7 @@ export class ConsumableGroupController { description: 'Gibt eine Verbrauchsgüter-Gruppe zurück', responseType: ConsumableGroupDto, notFound: true, - roles: [Role.DeviceTypeView], + roles: [Role.ConsumableGroupView], }) public getOne(@Param() params: IdNumberDto): Promise { return this.service.findOne(params.id); @@ -63,7 +63,7 @@ export class ConsumableGroupController { description: 'Erstellt eine Verbrauchsgüter-Gruppe', responseType: ConsumableGroupDto, notFound: true, - roles: [Role.DeviceTypeManage], + roles: [Role.ConsumableGroupManage], }) public create(@Body() body: ConsumableGroupCreateDto): Promise { return this.service.create(body); @@ -74,7 +74,7 @@ export class ConsumableGroupController { description: 'Aktualisiert eine Verbrauchsgüter-Gruppe', notFound: true, responseType: ConsumableGroupDto, - roles: [Role.DeviceTypeManage], + roles: [Role.ConsumableGroupManage], }) public update( @Param() params: IdNumberDto, @@ -88,7 +88,7 @@ export class ConsumableGroupController { description: 'Löscht eine Verbrauchsgüter-Gruppe', noContent: true, notFound: true, - roles: [Role.DeviceTypeManage], + roles: [Role.ConsumableGroupManage], }) public async delete(@Param() params: IdNumberDto): Promise { await this.service.delete(params.id); diff --git a/backend/src/inventory/consumable/consumable.controller.ts b/backend/src/inventory/consumable/consumable.controller.ts index 5990f36..4d3cd03 100644 --- a/backend/src/inventory/consumable/consumable.controller.ts +++ b/backend/src/inventory/consumable/consumable.controller.ts @@ -22,7 +22,7 @@ export class ConsumableController { path: 'count', description: 'Gibt die Anzahl aller Verbrauchsgüter zurück', responseType: CountDto, - roles: [Role.DeviceView], + roles: [Role.ConsumableView], }) public getCount(@Query() search: SearchDto): Promise { return this.service.getCount(search.searchTerm); @@ -32,7 +32,7 @@ export class ConsumableController { path: '', description: 'Gibt alle Verbrauchsgüter zurück', responseType: [ConsumableDto], - roles: [Role.DeviceView], + roles: [Role.ConsumableView], }) public async getAll( @Query() pagination: PaginationDto, @@ -55,7 +55,7 @@ export class ConsumableController { description: 'Gibt ein Verbrauchsgut zurück', responseType: ConsumableDto, notFound: true, - roles: [Role.DeviceView], + roles: [Role.ConsumableView], }) public getOne(@Param() params: IdNumberDto): Promise { return this.service.findOne(params.id); @@ -65,7 +65,7 @@ export class ConsumableController { description: 'Erstellt ein Verbrauchsgut', responseType: ConsumableDto, notFound: true, - roles: [Role.DeviceManage], + roles: [Role.ConsumableManage], }) public create(@Body() body: ConsumableCreateDto): Promise { return this.service.create(body); @@ -76,7 +76,7 @@ export class ConsumableController { description: 'Aktualisiert ein Verbrauchsgut', notFound: true, responseType: ConsumableDto, - roles: [Role.DeviceManage], + roles: [Role.ConsumableManage], }) public update( @Param() params: IdNumberDto, @@ -90,9 +90,37 @@ export class ConsumableController { description: 'Löscht ein Verbrauchsgut', noContent: true, notFound: true, - roles: [Role.DeviceManage], + roles: [Role.ConsumableManage], }) public async delete(@Param() params: IdNumberDto): Promise { await this.service.delete(params.id); } + + @Endpoint(EndpointType.POST, { + path: ':id/locations/:locationId', + description: 'Fügt einem Verbrauchsgut einen Standort hinzu', + responseType: ConsumableDto, + notFound: true, + roles: [Role.ConsumableManage], + }) + public addLocation( + @Param('id') id: number, + @Param('locationId') locationId: number, + ): Promise { + return this.service.addLocation(id, locationId); + } + + @Endpoint(EndpointType.DELETE, { + path: ':id/locations/:locationId', + description: 'Entfernt einen Standort von einem Verbrauchsgut', + responseType: ConsumableDto, + notFound: true, + roles: [Role.ConsumableManage], + }) + public removeLocation( + @Param('id') id: number, + @Param('locationId') locationId: number, + ): Promise { + return this.service.removeLocation(id, locationId); + } } \ No newline at end of file diff --git a/backend/src/inventory/consumable/consumable.service.ts b/backend/src/inventory/consumable/consumable.service.ts index 68f662a..20ffb7e 100644 --- a/backend/src/inventory/consumable/consumable.service.ts +++ b/backend/src/inventory/consumable/consumable.service.ts @@ -132,4 +132,52 @@ export class ConsumableService { } return plainToInstance(ConsumableDto, entity); } + + public async addLocation(id: number, locationId: number) { + // 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.locationRepo.findOne({ where: { id: locationId } }); + if (!location) { + throw new NotFoundException('Location not found'); + } + + // Add the location to the consumable + await this.repo + .createQueryBuilder() + .relation(ConsumableEntity, 'locations') + .of(id) + .add(locationId); + + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new InternalServerErrorException(); + } + return plainToInstance(ConsumableDto, entity); + } + + public async removeLocation(id: number, locationId: number) { + // Check if consumable exists + const consumable = await this.dbService.findOne(id); + if (!consumable) { + throw new NotFoundException('Consumable not found'); + } + + // Remove the location from the consumable + await this.repo + .createQueryBuilder() + .relation(ConsumableEntity, 'locations') + .of(id) + .remove(locationId); + + const entity = await this.dbService.findOne(id); + if (!entity) { + throw new InternalServerErrorException(); + } + return plainToInstance(ConsumableDto, entity); + } } \ No newline at end of file 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..3237dac --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.ts @@ -0,0 +1,46 @@ +import { Component } from '@angular/core'; +import { ConsumablesService } from '../consumables.service'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'ofs-consumable-list', + imports: [ + NzTableModule, + CommonModule + ], + standalone: true, + template: ` + + + + Name + Menge + Ablaufdatum + Gruppe + Standorte + Bemerkung + + + + + {{ entity.name || '-' }} + {{ entity.quantity }} + {{ entity.expirationDate || '-' }} + {{ entity.group?.name || '-' }} + + + {{ location.name }}, + + - + + {{ entity.notice || '-' }} + + + + `, + styleUrls: [] +}) +export class ConsumableListComponent { + constructor(public service: ConsumablesService) {} +} \ No newline at end of file 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..f084a4d --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumables.component.html @@ -0,0 +1 @@ + \ No newline at end of file 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..742511d --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumables.component.ts @@ -0,0 +1,20 @@ +import { Component, OnInit } from '@angular/core'; +import { ConsumablesService } from './consumables.service'; +import { ConsumableListComponent } from './consumable-list/consumable-list.component'; + +@Component({ + selector: 'app-consumables', + imports: [ + ConsumableListComponent + ], + standalone: true, + templateUrl: './consumables.component.html', + styleUrls: ['./consumables.component.less'] +}) +export class ConsumablesComponent implements OnInit { + constructor(private service: ConsumablesService) {} + + ngOnInit() { + this.service.load(); + } +} \ No newline at end of file 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..c8bb5f2 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumables.service.ts @@ -0,0 +1,84 @@ +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); + } + }); + } + + getOne(id: number): Observable { + return this.apiService.consumableControllerGetOne({id}); + } + + create(dto: ConsumableCreateDto): Observable { + return this.apiService.consumableControllerCreate({consumableCreateDto: dto}); + } + + update(id: number, dto: ConsumableUpdateDto): Observable { + return this.apiService.consumableControllerUpdate({id, consumableUpdateDto: dto}); + } + + delete(id: number): Observable { + return this.apiService.consumableControllerDelete({id}); + } + + addLocation(id: number, locationId: number): Observable { + return this.apiService.consumableControllerAddLocation({id, locationId}); + } + + removeLocation(id: number, locationId: number): Observable { + return this.apiService.consumableControllerRemoveLocation({id, locationId}); + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/inventory/inventory.routes.ts b/frontend/src/app/pages/inventory/inventory.routes.ts index 742cfc6..d15bfad 100644 --- a/frontend/src/app/pages/inventory/inventory.routes.ts +++ b/frontend/src/app/pages/inventory/inventory.routes.ts @@ -10,6 +10,7 @@ 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'; export const ROUTES: Route[] = [ { @@ -60,6 +61,11 @@ export const ROUTES: Route[] = [ { path: 'consumable-groups', component: ConsumableGroupsComponent, - canActivate: [roleGuard(['device-group.view'])], + canActivate: [roleGuard(['consumable-group.view'])], + }, + { + path: 'consumables', + component: ConsumablesComponent, + canActivate: [roleGuard(['consumable.view'])], }, ]; diff --git a/frontend/src/app/shared/models/consumable.models.ts b/frontend/src/app/shared/models/consumable.models.ts deleted file mode 100644 index 18fd850..0000000 --- a/frontend/src/app/shared/models/consumable.models.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface ConsumableGroupDto { - id: number; - name: string; - notice?: string; -} - -export interface ConsumableGroupCreateDto { - name: string; - notice?: string; -} - -export interface ConsumableGroupUpdateDto { - name?: string; - notice?: string; -} - -export interface ConsumableDto { - id: number; - name?: string; - notice?: string; - quantity: number; - expirationDate?: Date; - groupId?: number; - group?: ConsumableGroupDto; - locationId?: number; - location?: any; // LocationDto - will be imported later -} - -export interface ConsumableCreateDto { - name?: string; - notice?: string; - quantity: number; - expirationDate?: Date; - groupId?: number; - locationId?: number; -} - -export interface ConsumableUpdateDto { - name?: string; - notice?: string; - quantity?: number; - expirationDate?: Date; - groupId?: number; - locationId?: number; -} - -export interface CountDto { - count: number; -} \ No newline at end of file diff --git a/frontend/src/app/shared/services/consumable-api.service.ts b/frontend/src/app/shared/services/consumable-api.service.ts deleted file mode 100644 index 77cc2ef..0000000 --- a/frontend/src/app/shared/services/consumable-api.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { - ConsumableDto, - ConsumableCreateDto, - ConsumableUpdateDto, - CountDto -} from '../models/consumable.models'; - -@Injectable({ - providedIn: 'root' -}) -export class ConsumableApiService { - private readonly basePath = '/api/consumable'; - - 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; - groupId?: number; - locationId?: 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?.groupId) { - params = params.set('groupId', options.groupId.toString()); - } - if (options?.locationId) { - params = params.set('locationId', options.locationId.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: ConsumableCreateDto): Observable { - return this.http.post(this.basePath, dto); - } - - update(id: number, dto: ConsumableUpdateDto): 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/openapi/backend.yml b/openapi/backend.yml index 838cfb6..a501033 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -1471,11 +1471,13 @@ paths: in: query schema: type: number - - name: locationId + - name: locationIds required: false in: query schema: - type: number + type: array + items: + type: number - name: sortCol required: false in: query @@ -1611,6 +1613,67 @@ paths: summary: '' tags: - Consumable + /api/consumable/{id}/locations/{locationId}: + post: + description: Fügt einem Verbrauchsgut einen Standort hinzu + operationId: ConsumableController_addLocation + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: locationId + 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 + delete: + description: Entfernt einen Standort von einem Verbrauchsgut + operationId: ConsumableController_removeLocation + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: locationId + 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 info: title: OFS-Manager description: '' @@ -1933,11 +1996,15 @@ components: group: $ref: '#/components/schemas/ConsumableGroupDto' nullable: true - locationId: - type: number + locationIds: + type: array + items: + type: number nullable: true - location: - $ref: '#/components/schemas/LocationDto' + locations: + type: array + items: + $ref: '#/components/schemas/LocationDto' nullable: true required: - id @@ -1960,8 +2027,10 @@ components: groupId: type: number nullable: true - locationId: - type: number + locationIds: + type: array + items: + type: number nullable: true required: - quantity @@ -1983,8 +2052,10 @@ components: groupId: type: number nullable: true - locationId: - type: number + locationIds: + type: array + items: + type: number nullable: true LocationDto: type: object From 5e7d45d6e87b3e13dcec6d974601f7d9443c81b9 Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:05:40 +0200 Subject: [PATCH 12/16] small fixes --- backend/src/core/core.module.ts | 4 + openapi/backend.yml | 418 +++++++++++++++++++++++--------- 2 files changed, 305 insertions(+), 117 deletions(-) diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index 8f90513..d147639 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -28,6 +28,8 @@ 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 { AmqpService } from './services/amqp.service'; import { MinioListenerService } from './services/storage/minio-listener.service'; import { ImageService } from './services/storage/image.service'; @@ -66,6 +68,8 @@ import { InventoryModule } from '../inventory/inventory.module'; DeviceTypeEntity, DeviceGroupEntity, DeviceEntity, + ConsumableEntity, + ConsumableGroupEntity, DeviceImageEntity, ], extra: { diff --git a/openapi/backend.yml b/openapi/backend.yml index a501033..f458676 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -1048,6 +1048,7 @@ paths: summary: '' tags: - Device +<<<<<<< HEAD /api/device/{id}/image-upload: get: description: Gibt die URL zum Hochladen eines Gerätebildes zurück @@ -1253,6 +1254,8 @@ paths: summary: '' tags: - Location +======= +>>>>>>> 95edcf7 (small fixes) /api/consumable-group/count: get: description: Gibt die Anzahl aller Verbrauchsgüter-Gruppen zurück @@ -1302,6 +1305,9 @@ paths: in: query schema: type: string + enum: + - ASC + - DESC - name: searchTerm required: false in: query @@ -1470,11 +1476,13 @@ paths: required: false in: query schema: + nullable: true type: number - name: locationIds required: false in: query schema: + nullable: true type: array items: type: number @@ -1674,6 +1682,180 @@ paths: summary: '' tags: - Consumable + /api/location/count: + get: + description: Gibt die Anzahl aller Standorte zurück + operationId: LocationController_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: + - Location + /api/location: + get: + description: Gibt alle Standorte zurück + operationId: LocationController_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 + - name: searchTerm + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LocationDto' + '401': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Location + post: + description: Erstellt einen Standort + operationId: LocationController_create + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LocationCreateDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/LocationDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Location + /api/location/{id}: + get: + description: Gibt einen Standort zurück + operationId: LocationController_getOne + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/LocationDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Location + put: + description: Aktualisiert einen Standort + operationId: LocationController_update + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LocationUpdateDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/LocationDto' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Location + delete: + description: Löscht einen Standort + operationId: LocationController_delete + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '204': + description: '' + '401': + description: '' + '404': + description: '' + security: + - bearer: [] + summary: '' + tags: + - Location info: title: OFS-Manager description: '' @@ -1940,123 +2122,6 @@ components: nullable: true required: - name - 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 - required: - - name - ConsumableDto: - type: object - properties: - id: - type: number - name: - type: string - nullable: true - notice: - type: string - nullable: true - quantity: - type: number - expirationDate: - type: string - format: date - nullable: true - groupId: - type: number - nullable: true - group: - $ref: '#/components/schemas/ConsumableGroupDto' - nullable: true - locationIds: - type: array - items: - type: number - nullable: true - locations: - type: array - items: - $ref: '#/components/schemas/LocationDto' - nullable: true - required: - - id - - quantity - ConsumableCreateDto: - type: object - properties: - name: - type: string - nullable: true - notice: - type: string - nullable: true - quantity: - type: number - expirationDate: - type: string - format: date - nullable: true - groupId: - type: number - nullable: true - locationIds: - type: array - items: - type: number - nullable: true - required: - - quantity - ConsumableUpdateDto: - type: object - properties: - name: - type: string - nullable: true - notice: - type: string - nullable: true - quantity: - type: number - expirationDate: - type: string - format: date - nullable: true - groupId: - type: number - nullable: true - locationIds: - type: array - items: - type: number - nullable: true LocationDto: type: object properties: @@ -2285,6 +2350,7 @@ components: - $ref: '#/components/schemas/LocationDto' required: - state +<<<<<<< HEAD UploadUrlDto: type: object properties: @@ -2295,6 +2361,124 @@ components: required: - postURL - formData +======= + 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 + ConsumableDto: + type: object + properties: + id: + type: number + name: + type: string + nullable: true + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + format: date-time + type: string + nullable: true + groupId: + type: number + nullable: true + group: + nullable: true + allOf: + - $ref: '#/components/schemas/ConsumableGroupDto' + locationIds: + nullable: true + type: array + items: + type: string + locations: + nullable: true + type: array + items: + $ref: '#/components/schemas/LocationDto' + required: + - id + - quantity + ConsumableCreateDto: + type: object + properties: + name: + type: string + nullable: true + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + format: date-time + type: string + nullable: true + groupId: + type: number + nullable: true + locationIds: + nullable: true + type: array + items: + type: string + required: + - quantity + ConsumableUpdateDto: + type: object + properties: + name: + type: string + nullable: true + notice: + type: string + nullable: true + quantity: + type: number + expirationDate: + format: date-time + type: string + nullable: true + groupId: + type: number + nullable: true + locationIds: + nullable: true + type: array + items: + type: string +>>>>>>> 95edcf7 (small fixes) LocationCreateDto: type: object properties: From 61fc351c15d42357e81d57171d36331e21fd01fd Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:37:25 +0200 Subject: [PATCH 13/16] =?UTF-8?q?Verbrauchsmaterial-Gruppe=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/app.component.html | 8 +-- ...=> consumable-group-create.component.html} | 2 +- ...=> consumable-group-create.component.less} | 0 .../consumable-group-create.component.spec.ts | 23 ++++++++ ...s => consumable-group-create.component.ts} | 20 +++---- .../consumable-group-create.service.spec.ts | 16 ++++++ ....ts => consumable-group-create.service.ts} | 16 +++--- .../device-group-create.component.spec.ts | 23 -------- .../device-group-create.service.spec.ts | 16 ------ ...=> consumable-group-detail.component.html} | 6 +-- ...=> consumable-group-detail.component.less} | 0 .../consumable-group-detail.component.spec.ts | 23 ++++++++ ...s => consumable-group-detail.component.ts} | 20 +++---- .../consumable-group-detail.service.spec.ts | 16 ++++++ ....ts => consumable-group-detail.service.ts} | 18 +++---- .../device-group-detail.component.spec.ts | 23 -------- .../device-group-detail.service.spec.ts | 16 ------ ...l => consumable-group-list.component.html} | 16 +++--- ...s => consumable-group-list.component.less} | 0 .../consumable-group-list.component.spec.ts | 23 ++++++++ .../consumable-group-list.component.ts | 41 +++++++-------- .../device-group-list.component.spec.ts | 23 -------- .../device-group-list.component.ts | 52 ------------------- .../consumable-groups.component.html | 6 +-- .../device-group-create.component.html | 2 +- .../device-group-detail.component.ts | 2 +- .../device-group-detail.service.ts | 8 +-- .../app/pages/inventory/inventory.routes.ts | 16 ++++++ 28 files changed, 199 insertions(+), 236 deletions(-) rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/{device-group-create.component.html => consumable-group-create.component.html} (96%) rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/{device-group-create.component.less => consumable-group-create.component.less} (100%) create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.spec.ts rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/{device-group-create.component.ts => consumable-group-create.component.ts} (77%) create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.spec.ts rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/{device-group-create.service.ts => consumable-group-create.service.ts} (62%) delete mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts delete mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.spec.ts rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/{device-group-detail.component.html => consumable-group-detail.component.html} (92%) rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/{device-group-detail.component.less => consumable-group-detail.component.less} (100%) create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.spec.ts rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/{device-group-detail.component.ts => consumable-group-detail.component.ts} (83%) create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.spec.ts rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/{device-group-detail.service.ts => consumable-group-detail.service.ts} (78%) delete mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts delete mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.spec.ts rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/{device-group-list.component.html => consumable-group-list.component.html} (50%) rename frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/{device-group-list.component.less => consumable-group-list.component.less} (100%) create mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.spec.ts delete mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.spec.ts delete mode 100644 frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 241ca84..bb595e5 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -40,11 +40,11 @@

    Manager

  • Geräte-Gruppen
  • -
  • - Verbrauchsgüter +
  • + Verbrauchsmaterial
  • -
  • - Verbrauchsgüter-Gruppen +
  • + Verbrauchsmaterial-Gruppen
  • diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.html similarity index 96% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.html rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.html index 8bf5c13..3915e80 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.html +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.html @@ -1,4 +1,4 @@ -

    Geräte-Typ erstellen

    +

    Verbrauchsmaterial-Gruppe erstellen

    diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.less similarity index 100% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.less rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.less 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/device-group-create.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.ts similarity index 77% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.ts rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.ts index f8234b5..83dc9e1 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.ts +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.component.ts @@ -1,26 +1,26 @@ import {Component, OnDestroy, Signal} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; -import {Subject, takeUntil} from 'rxjs'; -import {DeviceGroupCreateService} from './device-group-create.service'; -import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; 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 DeviceGroupCreateForm { +interface ConsumableGroupCreateForm { name: FormControl; notice?: FormControl; } @Component({ - selector: 'ofs-device-group-create', + selector: 'ofs-consumable-group-create', imports: [ReactiveFormsModule, NzButtonModule, NzFormModule, NzInputModule], standalone: true, - templateUrl: './device-group-create.component.html', - styleUrl: './device-group-create.component.less' + templateUrl: './consumable-group-create.component.html', + styleUrl: './consumable-group-create.component.less' }) -export class DeviceGroupCreateComponent implements OnDestroy { - form = new FormGroup({ +export class ConsumableGroupCreateComponent implements OnDestroy { + form = new FormGroup({ name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(1), Validators.maxLength(100)] @@ -34,7 +34,7 @@ export class DeviceGroupCreateComponent implements OnDestroy { private destroy$ = new Subject(); constructor( - private readonly service: DeviceGroupCreateService, + private readonly service: ConsumableGroupCreateService, private readonly inAppMessagingService: InAppMessageService ) { this.createLoading = this.service.createLoading; 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/device-group-create.service.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.ts similarity index 62% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.ts rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.ts index 5f3df7d..b117219 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.ts +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/consumable-group-create.service.ts @@ -1,32 +1,32 @@ import {Injectable, signal} from '@angular/core'; -import {DeviceGroupService} from '@backend/api/deviceGroup.service'; -import {Router} from '@angular/router'; import {Subject} from 'rxjs'; -import {DeviceGroupCreateDto} from '@backend/model/deviceGroupCreateDto'; +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 DeviceGroupCreateService { +export class ConsumableGroupCreateService { createLoading = signal(false); createLoadingError = new Subject(); createLoadingSuccess = new Subject(); constructor( - private readonly apiService: DeviceGroupService, + private readonly apiService: ConsumableGroupService, private readonly router: Router, ) { } - create(rawValue: DeviceGroupCreateDto) { + create(rawValue: ConsumableGroupCreateDto) { this.createLoading.set(true); - this.apiService.deviceGroupControllerCreate({deviceGroupCreateDto: rawValue}) + this.apiService.consumableGroupControllerCreate({consumableGroupCreateDto: rawValue}) .subscribe({ next: (entity) => { this.createLoading.set(false); this.createLoadingSuccess.next(); - this.router.navigate(['inventory', 'device-groups', entity.id]); + this.router.navigate(['inventory', 'consumable-groups', entity.id]); }, error: (err: HttpErrorResponse) => { this.createLoading.set(false); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts deleted file mode 100644 index 6bd1b12..0000000 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DeviceGroupCreateComponent } from './device-group-create.component'; - -describe('DeviceGroupCreateComponent', () => { - let component: DeviceGroupCreateComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeviceGroupCreateComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(DeviceGroupCreateComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.spec.ts deleted file mode 100644 index 4496f46..0000000 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-create/device-group-create.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { DeviceGroupCreateService } from './device-group-create.service'; - -describe('DeviceGroupCreateService', () => { - let service: DeviceGroupCreateService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(DeviceGroupCreateService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.html similarity index 92% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.html rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.html index 9007dc0..66bea35 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.html +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.html @@ -1,9 +1,9 @@ -

    Geräte-Gruppe bearbeiten

    +

    Verbrauchsmaterial-Gruppe bearbeiten

    -

    Der Geräte-Gruppe wurde nicht gefunden!

    +

    Der Verbrauchsmaterial-Gruppe wurde nicht gefunden!

    - Geräte-Gruppe wird geladen... + Verbrauchsmaterial-Gruppe wird geladen... diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.less similarity index 100% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.less rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.less 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/device-group-detail.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.ts similarity index 83% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.ts rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.ts index 8712259..1942929 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.ts +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.component.ts @@ -10,23 +10,23 @@ 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'; -import {DeviceGroupDetailService} from './device-group-detail.service'; -interface DeviceGroupDetailForm { +interface ConsumableGroupDetailForm { name: FormControl; notice?: FormControl; } @Component({ - selector: 'ofs-device-group-detail', + selector: 'ofs-consumable-group-detail', imports: [NgIf, ReactiveFormsModule, NzButtonModule, NzFormModule, NzInputModule, NzCheckboxModule, NzPopconfirmModule, NzSelectModule, NzInputNumberModule ], standalone: true, - templateUrl: './device-group-detail.component.html', - styleUrl: './device-group-detail.component.less' + templateUrl: './consumable-group-detail.component.html', + styleUrl: './consumable-group-detail.component.less' }) -export class DeviceGroupDetailComponent implements OnInit, OnDestroy { +export class ConsumableGroupDetailComponent implements OnInit, OnDestroy { notFound: Signal; loading: Signal; loadingError: Signal; @@ -35,7 +35,7 @@ export class DeviceGroupDetailComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); - form = new FormGroup({ + form = new FormGroup({ name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(1), Validators.maxLength(100)] @@ -47,7 +47,7 @@ export class DeviceGroupDetailComponent implements OnInit, OnDestroy { constructor( private readonly activatedRoute: ActivatedRoute, - private readonly service: DeviceGroupDetailService, + private readonly service: ConsumableGroupDetailService, private readonly inAppMessagingService: InAppMessageService, ) { this.notFound = this.service.notFound; @@ -64,7 +64,7 @@ export class DeviceGroupDetailComponent implements OnInit, OnDestroy { effect(() => { const updateLoading = this.service.loadingError(); if (updateLoading) { - this.inAppMessagingService.showError('Fehler beim laden der Geräte-Gruppe.'); + this.inAppMessagingService.showError('Fehler beim laden der Verbrauchsmaterial-Gruppe.'); } }); @@ -73,7 +73,7 @@ export class DeviceGroupDetailComponent implements OnInit, OnDestroy { .subscribe((x) => this.inAppMessagingService.showError(x)); this.service.deleteLoadingSuccess .pipe(takeUntil(this.destroy$)) - .subscribe(() => this.inAppMessagingService.showSuccess('Geräte-Gruppe gelöscht')); + .subscribe(() => this.inAppMessagingService.showSuccess('Verbrauchsmaterial-Gruppe gelöscht')); this.service.updateLoadingError .pipe(takeUntil(this.destroy$)) .subscribe(() => this.inAppMessagingService.showError('Fehler beim speichern.')); 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/device-group-detail.service.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.ts similarity index 78% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.ts rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.ts index 912b548..f212e5a 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.ts +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/consumable-group-detail.service.ts @@ -1,17 +1,17 @@ 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 {DeviceGroupService} from '@backend/api/deviceGroup.service'; -import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; import {DeviceGroupUpdateDto} from '@backend/model/deviceGroupUpdateDto'; @Injectable({ providedIn: 'root' }) -export class DeviceGroupDetailService { +export class ConsumableGroupDetailService { id?: number; - entity = signal(null); + entity = signal(null); loading = signal(false); loadingError = signal(false); notFound = signal(false); @@ -23,7 +23,7 @@ export class DeviceGroupDetailService { deleteLoadingSuccess = new Subject(); constructor( - private readonly locationService: DeviceGroupService, + private readonly service: ConsumableGroupService, 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.consumableGroupControllerGetOne({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.consumableGroupControllerUpdate({id: entity.id, consumableGroupUpdateDto: rawValue}) .subscribe({ next: (newEntity) => { this.updateLoading.set(false); @@ -72,12 +72,12 @@ export class DeviceGroupDetailService { const entity = this.entity(); if (entity) { this.deleteLoading.set(true); - this.locationService.deviceGroupControllerDelete({id: entity.id}) + this.service.consumableGroupControllerDelete({id: entity.id}) .subscribe({ next: () => { this.deleteLoading.set(false); this.deleteLoadingSuccess.next(); - this.router.navigate(['inventory', 'device-groups']); + this.router.navigate(['inventory', 'consumable-groups']); }, error: (err: HttpErrorResponse) => { this.deleteLoading.set(false); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts deleted file mode 100644 index 1691d7f..0000000 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DeviceGroupDetailComponent } from './device-group-detail.component'; - -describe('DeviceGroupDetailComponent', () => { - let component: DeviceGroupDetailComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeviceGroupDetailComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(DeviceGroupDetailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.spec.ts deleted file mode 100644 index d5f1c74..0000000 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-detail/device-group-detail.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { DeviceGroupDetailService } from './device-group-detail.service'; - -describe('DeviceGroupDetailService', () => { - let service: DeviceGroupDetailService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(DeviceGroupDetailService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.html b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.html similarity index 50% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.html rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.html index d7b347e..7dfc35b 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.html +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.html @@ -1,21 +1,21 @@ Name - + - + {{ entity.name }} - + Details diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.less b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.less similarity index 100% rename from frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.less rename to frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/consumable-group-list.component.less 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 index af2ac12..d6bd52e 100644 --- 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 @@ -1,33 +1,32 @@ import {Component} from '@angular/core'; -import {ConsumableGroupsService} from '../consumable-groups.service'; -import {NzTableModule} from 'ng-zorro-antd/table'; 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 + CommonModule, + HasRoleDirective, + NzButtonComponent, + RouterLink ], standalone: true, - template: ` - - - - Name - Bemerkung - - - - - {{ entity.name }} - {{ entity.notice || '-' }} - - - - `, - styleUrls: [] + templateUrl: './consumable-group-list.component.html', + styleUrl: './consumable-group-list.component.less' }) export class ConsumableGroupListComponent { constructor(public service: ConsumableGroupsService) {} -} \ No newline at end of file + + 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-group-list/device-group-list.component.spec.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.spec.ts deleted file mode 100644 index 4406af2..0000000 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DeviceGroupListComponent } from './device-group-list.component'; - -describe('DeviceGroupListComponent', () => { - let component: DeviceGroupListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeviceGroupListComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(DeviceGroupListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts b/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts deleted file mode 100644 index 9b9331d..0000000 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-group-list/device-group-list.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {Component, effect, Signal} from '@angular/core'; -import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; -import { - NzTableModule, - NzTableQueryParams, -} from 'ng-zorro-antd/table'; -import {DeviceGroupsService} from '../device-groups.service'; -import {DeviceGroupDto} from '@backend/model/deviceGroupDto'; -import {HasRoleDirective} from '../../../../core/auth/has-role.directive'; -import {NgForOf} from '@angular/common'; -import {NzButtonModule} from 'ng-zorro-antd/button'; -import {NzTypographyModule} from 'ng-zorro-antd/typography'; -import {RouterLink} from '@angular/router'; -import {NzIconModule} from 'ng-zorro-antd/icon'; - -@Component({ - selector: 'ofs-device-group-list', - imports: [NzTableModule, NgForOf, HasRoleDirective, NzButtonModule, RouterLink, NzTypographyModule, NzIconModule], - templateUrl: './device-group-list.component.html', - styleUrl: './device-group-list.component.less' -}) -export class DeviceGroupListComponent { - entities: Signal; - total: Signal; - entitiesLoading: Signal; - itemsPerPage: number; - page: number; - - constructor( - private readonly service: DeviceGroupsService, - 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-Gruppen.'); - } - }); - } - - 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 index 327e31a..1686fe5 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html @@ -1,8 +1,8 @@ -

    Verbrauchsgüter-Gruppen

    +

    Verbrauchsmaterial-Gruppen

    -
    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/inventory.routes.ts b/frontend/src/app/pages/inventory/inventory.routes.ts index d15bfad..7e60916 100644 --- a/frontend/src/app/pages/inventory/inventory.routes.ts +++ b/frontend/src/app/pages/inventory/inventory.routes.ts @@ -11,6 +11,12 @@ import {DeviceCreateComponent} from './devices/device-create/device-create.compo 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'; export const ROUTES: Route[] = [ { @@ -63,6 +69,16 @@ export const ROUTES: Route[] = [ 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, From 598204e689d5c18f050b38ac2624874bfd0ffb2f Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:26:22 +0200 Subject: [PATCH 14/16] consumable CRUD consumable-group CRUD --- backend/src/base/location/location.entity.ts | 5 +- backend/src/base/location/location.module.ts | 1 + backend/src/core/core.module.ts | 2 + .../consumable/consumable-db.service.ts | 44 +++++- .../consumable/consumable-location.entity.ts | 41 ++++++ .../consumable/consumable.controller.ts | 32 +++- .../inventory/consumable/consumable.entity.ts | 21 +-- .../consumable/consumable.service.ts | 113 ++++++-------- .../consumable/dto/consumable-create.dto.ts | 5 +- .../dto/consumable-location-add.dto.ts | 7 + .../dto/consumable-location-update.dto.ts | 7 + .../consumable/dto/consumable-location.dto.ts | 55 +++++++ .../consumable/dto/consumable-update.dto.ts | 4 +- .../consumable/dto/consumable.dto.ts | 31 ++-- backend/src/inventory/inventory.module.ts | 4 + .../consumable-groups.component.html | 2 +- .../consumable-groups.service.ts | 7 +- .../consumable-create.component.html | 64 ++++++++ .../consumable-create.component.less | 0 .../consumable-create.component.spec.ts | 23 +++ .../consumable-create.component.ts | 83 +++++++++++ .../consumable-create.service.spec.ts | 16 ++ .../consumable-create.service.ts | 78 ++++++++++ .../consumable-detail.component.html | 74 ++++++++++ .../consumable-detail.component.less | 0 .../consumable-detail.component.spec.ts | 23 +++ .../consumable-detail.component.ts | 131 +++++++++++++++++ .../consumable-detail.service.spec.ts | 16 ++ .../consumable-detail.service.ts | 138 ++++++++++++++++++ .../consumable-list.component.html | 27 ++++ .../consumable-list.component.less | 0 .../consumable-list.component.spec.ts | 23 +++ .../consumable-list.component.ts | 83 ++++++----- .../consumables/consumables.component.html | 18 ++- .../consumables/consumables.component.ts | 30 +++- .../consumables/consumables.service.ts | 40 +++-- .../device-create.component.html | 3 +- .../device-detail.component.html | 2 +- .../app/pages/inventory/inventory.routes.ts | 12 ++ 39 files changed, 1077 insertions(+), 188 deletions(-) create mode 100644 backend/src/inventory/consumable/consumable-location.entity.ts create mode 100644 backend/src/inventory/consumable/dto/consumable-location-add.dto.ts create mode 100644 backend/src/inventory/consumable/dto/consumable-location-update.dto.ts create mode 100644 backend/src/inventory/consumable/dto/consumable-location.dto.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.html create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.less create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.component.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-create/consumable-create.service.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.html create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.less create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.spec.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.ts create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.html create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.less create mode 100644 frontend/src/app/pages/inventory/consumables/consumable-list/consumable-list.component.spec.ts diff --git a/backend/src/base/location/location.entity.ts b/backend/src/base/location/location.entity.ts index 3e71991..025dcba 100644 --- a/backend/src/base/location/location.entity.ts +++ b/backend/src/base/location/location.entity.ts @@ -10,6 +10,7 @@ import { } from 'typeorm'; import { DeviceEntity } from '../../inventory/device/device.entity'; import { ConsumableEntity } from '../../inventory/consumable/consumable.entity'; +import { ConsumableLocationEntity } from '../../inventory/consumable/consumable-location.entity'; export enum LocationType { NONE = 0, // Keine Angabe @@ -60,6 +61,6 @@ export class LocationEntity { @OneToMany(() => DeviceEntity, (x) => x.location) devices: DeviceEntity[]; - @ManyToMany(() => ConsumableEntity, (consumable) => consumable.locations) - consumables: ConsumableEntity[]; + @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/core.module.ts b/backend/src/core/core.module.ts index d147639..384c468 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -30,6 +30,7 @@ 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'; @@ -70,6 +71,7 @@ import { InventoryModule } from '../inventory/inventory.module'; DeviceEntity, ConsumableEntity, ConsumableGroupEntity, + ConsumableLocationEntity, DeviceImageEntity, ], extra: { diff --git a/backend/src/inventory/consumable/consumable-db.service.ts b/backend/src/inventory/consumable/consumable-db.service.ts index e09e53c..2c249eb 100644 --- a/backend/src/inventory/consumable/consumable-db.service.ts +++ b/backend/src/inventory/consumable/consumable-db.service.ts @@ -3,12 +3,15 @@ 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( @@ -42,7 +45,8 @@ export class ConsumableDbService { .limit(limit ?? 100) .offset(offset ?? 0) .leftJoinAndSelect('c.group', 'cg') - .leftJoinAndSelect('c.locations', 'l') + .leftJoinAndSelect('c.consumableLocations', 'cl') + .leftJoinAndSelect('cl.location', 'l') .leftJoinAndSelect('l.parent', 'lp'); if (searchTerm) { @@ -71,7 +75,8 @@ export class ConsumableDbService { .createQueryBuilder('c') .where('c.id = :id', { id }) .leftJoinAndSelect('c.group', 'cg') - .leftJoinAndSelect('c.locations', 'l') + .leftJoinAndSelect('c.consumableLocations', 'cl') + .leftJoinAndSelect('cl.location', 'l') .leftJoinAndSelect('l.parent', 'lp'); return query.getOne(); @@ -90,4 +95,37 @@ export class ConsumableDbService { const result = await this.repo.delete(id); return (result.affected ?? 0) > 0; } -} \ No newline at end of file + + 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 index 4d3cd03..d3796e8 100644 --- a/backend/src/inventory/consumable/consumable.controller.ts +++ b/backend/src/inventory/consumable/consumable.controller.ts @@ -13,6 +13,8 @@ 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 { @@ -97,7 +99,7 @@ export class ConsumableController { } @Endpoint(EndpointType.POST, { - path: ':id/locations/:locationId', + path: ':id/locations', description: 'Fügt einem Verbrauchsgut einen Standort hinzu', responseType: ConsumableDto, notFound: true, @@ -105,13 +107,13 @@ export class ConsumableController { }) public addLocation( @Param('id') id: number, - @Param('locationId') locationId: number, + @Body() body: ConsumableLocationAddDto, ): Promise { - return this.service.addLocation(id, locationId); + return this.service.addLocation(id, body); } @Endpoint(EndpointType.DELETE, { - path: ':id/locations/:locationId', + path: ':id/locations/:relationId', description: 'Entfernt einen Standort von einem Verbrauchsgut', responseType: ConsumableDto, notFound: true, @@ -119,8 +121,24 @@ export class ConsumableController { }) public removeLocation( @Param('id') id: number, - @Param('locationId') locationId: number, + @Param('relationId') relationId: number, ): Promise { - return this.service.removeLocation(id, locationId); + return this.service.removeLocation(id, relationId); } -} \ No newline at end of file + + @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 index 4358189..90a1f61 100644 --- a/backend/src/inventory/consumable/consumable.entity.ts +++ b/backend/src/inventory/consumable/consumable.entity.ts @@ -1,6 +1,12 @@ -import { Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; import { ConsumableGroupEntity } from '../consumable-group/consumable-group.entity'; -import { LocationEntity } from '../../base/location/location.entity'; +import { ConsumableLocationEntity } from './consumable-location.entity'; @Entity() export class ConsumableEntity { @@ -11,10 +17,6 @@ export class ConsumableEntity { name?: string; @Column({ type: 'text', nullable: true }) notice?: string; - @Column({ type: 'int' }) - quantity: number; - @Column({ type: 'date', nullable: true }) - expirationDate?: Date; @Column({ nullable: true }) groupId?: number; @@ -25,7 +27,6 @@ export class ConsumableEntity { }) group?: ConsumableGroupEntity; - @ManyToMany(() => LocationEntity, (location) => location.consumables) - @JoinTable() - locations?: LocationEntity[]; -} \ No newline at end of file + @OneToMany(() => ConsumableLocationEntity, (x) => x.consumable) + consumableLocations?: ConsumableLocationEntity[]; +} diff --git a/backend/src/inventory/consumable/consumable.service.ts b/backend/src/inventory/consumable/consumable.service.ts index 20ffb7e..b07a1ad 100644 --- a/backend/src/inventory/consumable/consumable.service.ts +++ b/backend/src/inventory/consumable/consumable.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, InternalServerErrorException, NotFoundException, @@ -13,15 +14,15 @@ 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, - @InjectRepository(ConsumableEntity) - private readonly repo: Repository, - @InjectRepository(LocationEntity) - private readonly locationRepo: Repository, + private readonly locationDbService: LocationDbService, ) {} public async findAll( @@ -65,24 +66,8 @@ export class ConsumableService { } public async create(body: ConsumableCreateDto) { - // Extract locationIds from the body - const { locationIds, ...consumableData } = body; - - // Create the consumable entity first - const newEntity = await this.dbService.create(consumableData); - - // Handle location associations if provided - if (locationIds && locationIds.length > 0) { - const locations = await this.locationRepo.findByIds(locationIds); - if (locations.length > 0) { - await this.repo - .createQueryBuilder() - .relation(ConsumableEntity, 'locations') - .of(newEntity.id) - .add(locations.map(l => l.id)); - } - } - + const newEntity = await this.dbService.create(body); + const entity = await this.dbService.findOne(newEntity.id); if (!entity) { throw new InternalServerErrorException(); @@ -91,41 +76,10 @@ export class ConsumableService { } public async update(id: number, body: ConsumableUpdateDto) { - // Extract locationIds from the body - const { locationIds, ...consumableData } = body; - - // Update the consumable entity first - if (!(await this.dbService.update(id, consumableData))) { + if (!(await this.dbService.update(id, body))) { throw new NotFoundException(); } - // Handle location associations if provided - if (locationIds !== undefined) { - // First, remove all existing location associations - await this.repo - .createQueryBuilder() - .relation(ConsumableEntity, 'locations') - .of(id) - .remove(await this.repo - .createQueryBuilder('c') - .relation('locations') - .of(id) - .loadMany() - ); - - // Then add new associations if any - if (locationIds.length > 0) { - const locations = await this.locationRepo.findByIds(locationIds); - if (locations.length > 0) { - await this.repo - .createQueryBuilder() - .relation(ConsumableEntity, 'locations') - .of(id) - .add(locations.map(l => l.id)); - } - } - } - const entity = await this.dbService.findOne(id); if (!entity) { throw new NotFoundException(); @@ -133,7 +87,7 @@ export class ConsumableService { return plainToInstance(ConsumableDto, entity); } - public async addLocation(id: number, locationId: number) { + public async addLocation(id: number, body: ConsumableLocationAddDto) { // Check if consumable exists const consumable = await this.dbService.findOne(id); if (!consumable) { @@ -141,18 +95,25 @@ export class ConsumableService { } // Check if location exists - const location = await this.locationRepo.findOne({ where: { id: locationId } }); + const location = await this.locationDbService.findOne(body.locationId); if (!location) { throw new NotFoundException('Location not found'); } // Add the location to the consumable - await this.repo - .createQueryBuilder() - .relation(ConsumableEntity, 'locations') - .of(id) - .add(locationId); + 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(); @@ -160,19 +121,33 @@ export class ConsumableService { return plainToInstance(ConsumableDto, entity); } - public async removeLocation(id: number, locationId: number) { + public async updateLocation( + id: number, + relationId: number, + data: ConsumableLocationUpdateDto, + ) { + // Check if relation exists + const relation = await this.dbService.findLocationRelation(id, relationId); + 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'); } - // Remove the location from the consumable - await this.repo - .createQueryBuilder() - .relation(ConsumableEntity, 'locations') - .of(id) - .remove(locationId); + // 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) { @@ -180,4 +155,4 @@ export class ConsumableService { } return plainToInstance(ConsumableDto, entity); } -} \ No newline at end of file +} diff --git a/backend/src/inventory/consumable/dto/consumable-create.dto.ts b/backend/src/inventory/consumable/dto/consumable-create.dto.ts index ca2d0e6..ac08bcb 100644 --- a/backend/src/inventory/consumable/dto/consumable-create.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable-create.dto.ts @@ -4,5 +4,6 @@ import { ConsumableDto } from './consumable.dto'; export class ConsumableCreateDto extends OmitType(ConsumableDto, [ 'id', 'group', - 'locations', -] as const) {} \ No newline at end of file + 'consumableLocations', + 'consumableLocationIds', +] as const) {} 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..a00d12e --- /dev/null +++ b/backend/src/inventory/consumable/dto/consumable-location.dto.ts @@ -0,0 +1,55 @@ +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: false, nullable: true }) + @Expose() + @IsOptional() + @IsInt() + @IsPositive() + locationId: number; + + @ApiProperty({ required: false, nullable: true, 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 index dc0bf02..e4b66b9 100644 --- a/backend/src/inventory/consumable/dto/consumable-update.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable-update.dto.ts @@ -1,6 +1,4 @@ import { PartialType } from '@nestjs/swagger'; import { ConsumableCreateDto } from './consumable-create.dto'; -export class ConsumableUpdateDto extends PartialType( - ConsumableCreateDto, -) {} \ No newline at end of file +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 index 7398c66..7350c05 100644 --- a/backend/src/inventory/consumable/dto/consumable.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; import { - IsDate, IsDefined, IsInt, IsOptional, @@ -9,8 +8,8 @@ import { IsString, MaxLength, } from 'class-validator'; -import { LocationDto } from '../../../base/location/dto/location.dto'; import { ConsumableGroupDto } from '../../consumable-group/dto/consumable-group.dto'; +import { ConsumableLocationDto } from './consumable-location.dto'; export class ConsumableDto { @ApiProperty() @@ -34,20 +33,6 @@ export class ConsumableDto { @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: false, nullable: true }) @Expose() @IsOptional() @@ -65,10 +50,14 @@ export class ConsumableDto { @IsOptional() @IsInt({ each: true }) @IsPositive({ each: true }) - locationIds?: number[]; + consumableLocationIds?: number[]; - @ApiProperty({ required: false, nullable: true, type: [LocationDto] }) + @ApiProperty({ + required: false, + nullable: true, + type: [ConsumableLocationDto], + }) @Expose() - @Type(() => LocationDto) - locations?: LocationDto[]; -} \ No newline at end of file + @Type(() => ConsumableLocationDto) + consumableLocations?: ConsumableLocationDto[]; +} diff --git a/backend/src/inventory/inventory.module.ts b/backend/src/inventory/inventory.module.ts index 12bf8ca..522d14f 100644 --- a/backend/src/inventory/inventory.module.ts +++ b/backend/src/inventory/inventory.module.ts @@ -21,6 +21,8 @@ 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({ @@ -31,9 +33,11 @@ import { DeviceImageEntity } from './device/device-image.entity'; DeviceEntity, ConsumableGroupEntity, ConsumableEntity, + ConsumableLocationEntity, LocationEntity, DeviceImageEntity, ]), + LocationModule, ], controllers: [ DeviceTypeController, 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 index 1686fe5..2646c54 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.component.html @@ -1,7 +1,7 @@

    Verbrauchsmaterial-Gruppen

    -
    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 index e7cfb0d..5230596 100644 --- a/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts +++ b/frontend/src/app/pages/inventory/consumable-groups/consumable-groups.service.ts @@ -19,8 +19,9 @@ export class ConsumableGroupsService { searchTerm$ = new BehaviorSubject<{ propagate: boolean, value: string }>({propagate: true, value: ''}); private searchTerm?: string; - constructor(private readonly apiService: ConsumableGroupService, - @Inject(SEARCH_DEBOUNCE_TIME) time: number, + constructor( + private readonly apiService: ConsumableGroupService, + @Inject(SEARCH_DEBOUNCE_TIME) time: number, ) { this.searchTerm$ .pipe( @@ -42,7 +43,7 @@ export class ConsumableGroupsService { limit: this.itemsPerPage, offset: (this.page - 1) * this.itemsPerPage, sortCol: this.sortCol, - sortDir: this.sortDir, + sortDir: (this.sortDir === 'ASC' || this.sortDir === 'DESC') ? this.sortDir : undefined, searchTerm: this.searchTerm }) .subscribe({ 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..d2e4f65 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.html @@ -0,0 +1,74 @@ +

    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. + } + + + + + + + + + + +
    +
    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..e5f17e8 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.ts @@ -0,0 +1,131 @@ +import {Component, effect, OnDestroy, OnInit, Signal} from '@angular/core'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {Subject, takeUntil} from 'rxjs'; +import {ConsumableGroupDto} from '@backend/model/consumableGroupDto'; +import {ActivatedRoute} from '@angular/router'; +import {InAppMessageService} from '../../../../shared/services/in-app-message.service'; +import {ConsumableDetailService} from './consumable-detail.service'; +import {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'; + +interface ConsumableUpdateForm { + name: FormControl; + notice: FormControl; + groupId: FormControl; +} + +@Component({ + selector: 'ofs-consumable-detail', + imports: [ + NgIf, + ReactiveFormsModule, + NzButtonModule, + NzFormModule, + NzInputModule, + NzCheckboxModule, + NzPopconfirmModule, + NzSelectModule, + NzInputNumberModule, + NzSpinModule, + NzDatePickerModule, + ], + templateUrl: './consumable-detail.component.html', + 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)] + }), + }); + + consumableGroups: Signal; + consumableGroupsIsLoading: Signal; + + constructor( + private readonly activatedRoute: ActivatedRoute, + private readonly service: ConsumableDetailService, + 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; + + this.consumableGroups = this.service.consumableGroups; + this.consumableGroupsIsLoading = this.service.consumableGroupsIsLoading; + + effect(() => { + const entity = this.service.entity(); + if (entity) this.form.patchValue(entity as any); + }); + + effect(() => { + const updateLoading = this.service.loadingError(); + if (updateLoading) { + this.inAppMessagingService.showError('Fehler beim laden des Geräts.'); + } + }); + this.service.deleteLoadingError + .pipe(takeUntil(this.destroy$)) + .subscribe((x) => this.inAppMessagingService.showError(x)); + this.service.deleteLoadingSuccess + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.inAppMessagingService.showSuccess('Gerät 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 + .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); + } +} 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..b87c412 --- /dev/null +++ b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.ts @@ -0,0 +1,138 @@ +import {Inject, Injectable, 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'; + +@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); + updateLoadingError = new Subject(); + deleteLoadingError = new Subject(); + updateLoadingSuccess = new Subject(); + deleteLoadingSuccess = 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(); + }); + } + + + 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.loadingError.set(true); + 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.updateLoadingSuccess.next(); + }, + error: () => { + this.updateLoading.set(false); + this.updateLoadingError.next(); + }, + }); + } + } + + delete() { + const entity = this.entity(); + if (entity) { + this.deleteLoading.set(true); + this.apiService.consumableControllerDelete({id: entity.id}) + .subscribe({ + next: () => { + this.deleteLoading.set(false); + this.deleteLoadingSuccess.next(); + this.router.navigate(['inventory', 'consumables']); + }, + error: (err: HttpErrorResponse) => { + this.deleteLoading.set(false); + this.deleteLoadingError.next(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([]); + } + }); + } + + onSearchGroup(value: string): void { + this.consumableGroupsSearch$.next({propagate: true, value}); + } +} 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 index 3237dac..e3d555b 100644 --- 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 @@ -1,46 +1,55 @@ -import { Component } from '@angular/core'; -import { ConsumablesService } from '../consumables.service'; -import { NzTableModule } from 'ng-zorro-antd/table'; -import { CommonModule } from '@angular/common'; +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 + CommonModule, + HasRoleDirective, + NzButtonComponent, + RouterLink ], standalone: true, - template: ` - - - - Name - Menge - Ablaufdatum - Gruppe - Standorte - Bemerkung - - - - - {{ entity.name || '-' }} - {{ entity.quantity }} - {{ entity.expirationDate || '-' }} - {{ entity.group?.name || '-' }} - - - {{ location.name }}, - - - - - {{ entity.notice || '-' }} - - - - `, - styleUrls: [] + templateUrl: './consumable-list.component.html', + styleUrl: './consumable-list.component.less' }) export class ConsumableListComponent { - constructor(public service: ConsumablesService) {} -} \ No newline at end of file + + 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 index f084a4d..a6f955c 100644 --- a/frontend/src/app/pages/inventory/consumables/consumables.component.html +++ b/frontend/src/app/pages/inventory/consumables/consumables.component.html @@ -1 +1,17 @@ - \ No newline at end of file +

    Verbrauchsmaterial

    +
    +
    + +
    +
    + + + + + + +
    +
    + diff --git a/frontend/src/app/pages/inventory/consumables/consumables.component.ts b/frontend/src/app/pages/inventory/consumables/consumables.component.ts index 742511d..330cd97 100644 --- a/frontend/src/app/pages/inventory/consumables/consumables.component.ts +++ b/frontend/src/app/pages/inventory/consumables/consumables.component.ts @@ -1,11 +1,28 @@ 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: 'app-consumables', + selector: 'ofs-consumables', imports: [ - ConsumableListComponent + ConsumableListComponent, + HasRoleDirective, + NzButtonComponent, + NzColDirective, + NzIconDirective, + NzInputDirective, + NzInputGroupComponent, + NzInputGroupWhitSuffixOrPrefixDirective, + NzRowDirective, + NzWaveDirective, + RouterLink ], standalone: true, templateUrl: './consumables.component.html', @@ -14,7 +31,12 @@ import { ConsumableListComponent } from './consumable-list/consumable-list.compo 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.load(); + this.service.init(); } -} \ No newline at end of file +} diff --git a/frontend/src/app/pages/inventory/consumables/consumables.service.ts b/frontend/src/app/pages/inventory/consumables/consumables.service.ts index c8bb5f2..cc7586a 100644 --- a/frontend/src/app/pages/inventory/consumables/consumables.service.ts +++ b/frontend/src/app/pages/inventory/consumables/consumables.service.ts @@ -1,7 +1,7 @@ -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'; +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' @@ -58,27 +58,21 @@ export class ConsumablesService { }); } - getOne(id: number): Observable { - return this.apiService.consumableControllerGetOne({id}); + 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(); } - create(dto: ConsumableCreateDto): Observable { - return this.apiService.consumableControllerCreate({consumableCreateDto: dto}); + search(term: string) { + this.searchTerm$.next({propagate: true, value: term}); + this.page = 1; } - update(id: number, dto: ConsumableUpdateDto): Observable { - return this.apiService.consumableControllerUpdate({id, consumableUpdateDto: dto}); + init() { + this.searchTerm = ''; + this.searchTerm$.next({propagate: false, value: ''}); } - - delete(id: number): Observable { - return this.apiService.consumableControllerDelete({id}); - } - - addLocation(id: number, locationId: number): Observable { - return this.apiService.consumableControllerAddLocation({id, locationId}); - } - - removeLocation(id: number, locationId: number): Observable { - return this.apiService.consumableControllerRemoveLocation({id, locationId}); - } -} \ No newline at end of file +} diff --git a/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html b/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html index ecdb9fd..d25b69f 100644 --- a/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html +++ b/frontend/src/app/pages/inventory/devices/device-create/device-create.component.html @@ -88,7 +88,8 @@

    Geräte erstellen

    nzPlaceHolder="Geräte-Gruppe auswählen" nzAllowClear nzShowSearch - nzServerSearch> + nzServerSearch + [nzLoading]="deviceGroupsIsLoading()"> @if (deviceGroupsIsLoading()) { } @else { 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 df5c0bc..fae3d25 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,6 +1,6 @@

    Gerät bearbeiten

    -

    Der Gerät wurde nicht gefunden!

    +

    Das Gerät wurde nicht gefunden!

    Gerät wird geladen... diff --git a/frontend/src/app/pages/inventory/inventory.routes.ts b/frontend/src/app/pages/inventory/inventory.routes.ts index 7e60916..1cd5a0c 100644 --- a/frontend/src/app/pages/inventory/inventory.routes.ts +++ b/frontend/src/app/pages/inventory/inventory.routes.ts @@ -17,6 +17,8 @@ import { 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[] = [ { @@ -84,4 +86,14 @@ export const ROUTES: Route[] = [ component: ConsumablesComponent, canActivate: [roleGuard(['consumable.view'])], }, + { + path: 'consumables/create', + component: ConsumableCreateComponent, + canActivate: [roleGuard(['consumable.manage'])], + }, + { + path: 'consumables/:id', + component: ConsumableDetailComponent, + canActivate: [roleGuard(['consumable.manage'])], + }, ]; From d65b5c1351954b861e3c2a060187d9d108d4fd27 Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:32:55 +0200 Subject: [PATCH 15/16] image upload --- backend/src/core/configuration.ts | 4 +- .../src/core/services/amqp.service.spec.ts | 2 +- .../core/services/request-context.service.ts | 4 +- .../core/services/storage/image.service.ts | 33 +- .../storage/minio-listener.service.ts | 2 +- .../core/services/storage/minio.service.ts | 39 +- backend/src/core/user/user.controller.ts | 2 +- backend/src/core/user/user.service.ts | 2 +- .../consumable-group-db.service.ts | 2 +- .../consumable-group.controller.ts | 6 +- .../consumable-group.entity.ts | 2 +- .../consumable-group.service.spec.ts | 2 +- .../consumable-group.service.ts | 2 +- .../dto/consumable-group-create.dto.ts | 2 +- .../dto/consumable-group-get-query.dto.ts | 2 +- .../dto/consumable-group-update.dto.ts | 2 +- .../dto/consumable-group.dto.ts | 2 +- .../consumable/consumable.service.spec.ts | 85 +---- .../consumable/consumable.service.ts | 4 - .../dto/consumable-get-query.dto.ts | 17 +- .../consumable/dto/consumable-location.dto.ts | 1 - .../src/inventory/device/device-db.service.ts | 14 +- .../inventory/device/device-image.entity.ts | 4 +- .../src/inventory/device/device.controller.ts | 33 ++ backend/src/inventory/device/device.entity.ts | 20 +- .../src/inventory/device/device.service.ts | 64 +++- .../inventory/device/dto/device-create.dto.ts | 5 + .../inventory/device/dto/device-image.dto.ts | 4 + .../inventory/device/dto/device-update.dto.ts | 4 + .../src/inventory/device/dto/device.dto.ts | 15 +- backend/src/shared/dto/download-url.dto.ts | 8 + backend/src/shared/dto/filename.dto.ts | 1 - .../shared/dto/image-download-querys.dto.ts | 10 + backend/src/shared/dto/image-id-guid.dto.ts | 12 + backend/src/shared/dto/upload-url.dto.ts | 8 +- .../custom-class-serializer.interceptor.ts | 2 +- docker-compose.dev.yml | 1 + docker/certs/private.key | 52 +++ docker/certs/public.crt | 32 ++ .../device-detail.component.html | 28 ++ .../device-detail/device-detail.component.ts | 64 ++-- .../device-detail/device-detail.service.ts | 76 +++- frontend/src/app/shared/config.ts | 9 + .../image-upload-area.component.html | 2 +- frontend/src/styles.less | 5 + openapi/backend.yml | 332 ++++++++---------- 46 files changed, 661 insertions(+), 361 deletions(-) create mode 100644 backend/src/shared/dto/download-url.dto.ts create mode 100644 backend/src/shared/dto/image-download-querys.dto.ts create mode 100644 backend/src/shared/dto/image-id-guid.dto.ts create mode 100644 docker/certs/private.key create mode 100644 docker/certs/public.crt create mode 100644 frontend/src/app/shared/config.ts diff --git a/backend/src/core/configuration.ts b/backend/src/core/configuration.ts index 66fd7a5..1a2f42c 100644 --- a/backend/src/core/configuration.ts +++ b/backend/src/core/configuration.ts @@ -47,8 +47,8 @@ const MinioConfig = registerAs(ConfigKey.Minio, () => ({ 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, })); diff --git a/backend/src/core/services/amqp.service.spec.ts b/backend/src/core/services/amqp.service.spec.ts index 8c2ba32..fb8c82e 100644 --- a/backend/src/core/services/amqp.service.spec.ts +++ b/backend/src/core/services/amqp.service.spec.ts @@ -15,4 +15,4 @@ describe('AmqpService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); -}); \ No newline at end of file +}); 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/storage/image.service.ts b/backend/src/core/services/storage/image.service.ts index d579f46..853ef36 100644 --- a/backend/src/core/services/storage/image.service.ts +++ b/backend/src/core/services/storage/image.service.ts @@ -1,5 +1,4 @@ -import { Injectable } from '@nestjs/common'; -import { BucketEventRecordS3Object } from './dto/minio-listener/bucket-event.dto'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { MinioService } from './minio.service'; import * as sharp from 'sharp'; import { Sharp } from 'sharp'; @@ -8,16 +7,17 @@ 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, - bucketObject: BucketEventRecordS3Object, - ) { + public async processDevice(key: string) { try { const img = await this.loadImage(key); await this.convertImage(key, img); @@ -54,15 +54,20 @@ export class ImageService { } private async blurredImage(key: string, img: Sharp) { - const size = 600; - const buffer = await img - .resize(size, null, { fit: 'inside' }) - .blur(40) - .toBuffer(); + 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', - }); + await this.minioService.putObject( + key + '-webp-' + size + '-blur', + buffer, + { + 'Content-Type': 'image/webp', + }, + ); + } } private async loadImage(key: string): Promise { diff --git a/backend/src/core/services/storage/minio-listener.service.ts b/backend/src/core/services/storage/minio-listener.service.ts index 8b807b7..636902b 100644 --- a/backend/src/core/services/storage/minio-listener.service.ts +++ b/backend/src/core/services/storage/minio-listener.service.ts @@ -65,7 +65,7 @@ export class MinioListenerService implements OnApplicationBootstrap { if (key.startsWith('devices/')) { if (imageType) { - await services.imageService.processDevice(key, bucketObject); + await services.imageService.processDevice(key); } } } diff --git a/backend/src/core/services/storage/minio.service.ts b/backend/src/core/services/storage/minio.service.ts index 510c0ea..3b77b29 100644 --- a/backend/src/core/services/storage/minio.service.ts +++ b/backend/src/core/services/storage/minio.service.ts @@ -1,6 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; +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 { @@ -8,7 +9,6 @@ export class MinioService { 'image/jpeg', 'image/png', 'image/tiff', - 'image/heic', ]; private client: Client; @@ -21,6 +21,8 @@ export class MinioService { 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'); @@ -63,6 +65,14 @@ export class MinioService { ); } + public async generatePresignedGetUrl(file: string): Promise { + return this.client.presignedGetObject( + this.bucketName, + file, + this.downloadExpiry, + ); + } + public async generatePresignedPostUrl( file: string, contentType: string, @@ -70,7 +80,7 @@ export class MinioService { ): Promise<{ postURL: string; formData: { - [key: string]: any; + [key: string]: unknown; }; }> { const policy = this.client.newPostPolicy(); @@ -93,7 +103,11 @@ export class MinioService { return this.client.getObject(this.bucketName, key); } - public putObject(key: string, webpBuffer: Buffer, metaData?: ItemBucketMetadata) { + public putObject( + key: string, + webpBuffer: Buffer, + metaData?: ItemBucketMetadata, + ) { return this.client.putObject( this.bucketName, key, @@ -102,4 +116,21 @@ export class MinioService { 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 index 19ee493..76467c2 100644 --- a/backend/src/inventory/consumable-group/consumable-group-db.service.ts +++ b/backend/src/inventory/consumable-group/consumable-group-db.service.ts @@ -74,4 +74,4 @@ export class ConsumableGroupDbService { const result = await this.repo.delete(id); return (result.affected ?? 0) > 0; } -} \ No newline at end of file +} diff --git a/backend/src/inventory/consumable-group/consumable-group.controller.ts b/backend/src/inventory/consumable-group/consumable-group.controller.ts index 9fb3a6d..7c1d5b6 100644 --- a/backend/src/inventory/consumable-group/consumable-group.controller.ts +++ b/backend/src/inventory/consumable-group/consumable-group.controller.ts @@ -65,7 +65,9 @@ export class ConsumableGroupController { notFound: true, roles: [Role.ConsumableGroupManage], }) - public create(@Body() body: ConsumableGroupCreateDto): Promise { + public create( + @Body() body: ConsumableGroupCreateDto, + ): Promise { return this.service.create(body); } @@ -93,4 +95,4 @@ export class ConsumableGroupController { public async delete(@Param() params: IdNumberDto): Promise { await this.service.delete(params.id); } -} \ No newline at end of file +} diff --git a/backend/src/inventory/consumable-group/consumable-group.entity.ts b/backend/src/inventory/consumable-group/consumable-group.entity.ts index bf70a8e..c4cc88e 100644 --- a/backend/src/inventory/consumable-group/consumable-group.entity.ts +++ b/backend/src/inventory/consumable-group/consumable-group.entity.ts @@ -13,4 +13,4 @@ export class ConsumableGroupEntity { @OneToMany(() => ConsumableEntity, (x) => x.group) consumables: ConsumableEntity[]; -} \ No newline at end of file +} diff --git a/backend/src/inventory/consumable-group/consumable-group.service.spec.ts b/backend/src/inventory/consumable-group/consumable-group.service.spec.ts index 552357b..a6c3d9f 100644 --- a/backend/src/inventory/consumable-group/consumable-group.service.spec.ts +++ b/backend/src/inventory/consumable-group/consumable-group.service.spec.ts @@ -65,4 +65,4 @@ describe('ConsumableGroupService', () => { expect(result).toEqual(mockCount); expect(dbService.getCount).toHaveBeenCalledWith(undefined); }); -}); \ No newline at end of file +}); diff --git a/backend/src/inventory/consumable-group/consumable-group.service.ts b/backend/src/inventory/consumable-group/consumable-group.service.ts index 79ffe5f..a074742 100644 --- a/backend/src/inventory/consumable-group/consumable-group.service.ts +++ b/backend/src/inventory/consumable-group/consumable-group.service.ts @@ -70,4 +70,4 @@ export class ConsumableGroupService { } return plainToInstance(ConsumableGroupDto, entity); } -} \ No newline at end of file +} 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 index 707f6f6..4927d69 100644 --- a/backend/src/inventory/consumable-group/dto/consumable-group-create.dto.ts +++ b/backend/src/inventory/consumable-group/dto/consumable-group-create.dto.ts @@ -3,4 +3,4 @@ import { ConsumableGroupDto } from './consumable-group.dto'; export class ConsumableGroupCreateDto extends OmitType(ConsumableGroupDto, [ 'id', -] as const) {} \ No newline at end of file +] 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 index bc57c40..df18207 100644 --- 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 @@ -26,4 +26,4 @@ export class ConsumableGroupGetQueryDto { @IsOptional() @IsString() searchTerm?: string; -} \ No newline at end of file +} 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 index a935e19..d1bd867 100644 --- a/backend/src/inventory/consumable-group/dto/consumable-group-update.dto.ts +++ b/backend/src/inventory/consumable-group/dto/consumable-group-update.dto.ts @@ -3,4 +3,4 @@ import { ConsumableGroupCreateDto } from './consumable-group-create.dto'; export class ConsumableGroupUpdateDto extends PartialType( ConsumableGroupCreateDto, -) {} \ No newline at end of file +) {} diff --git a/backend/src/inventory/consumable-group/dto/consumable-group.dto.ts b/backend/src/inventory/consumable-group/dto/consumable-group.dto.ts index 13d0b37..70f3341 100644 --- a/backend/src/inventory/consumable-group/dto/consumable-group.dto.ts +++ b/backend/src/inventory/consumable-group/dto/consumable-group.dto.ts @@ -28,4 +28,4 @@ export class ConsumableGroupDto { @IsOptional() @MaxLength(2000) notice?: string; -} \ No newline at end of file +} diff --git a/backend/src/inventory/consumable/consumable.service.spec.ts b/backend/src/inventory/consumable/consumable.service.spec.ts index ad5bdac..3f75875 100644 --- a/backend/src/inventory/consumable/consumable.service.spec.ts +++ b/backend/src/inventory/consumable/consumable.service.spec.ts @@ -6,12 +6,12 @@ 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 consumableRepo: Repository; - let locationRepo: Repository; + let locationDbService: LocationDbService; const mockDbService = { findAll: jest.fn(), @@ -22,21 +22,7 @@ describe('ConsumableService', () => { getCount: jest.fn(), }; - const mockConsumableRepo = { - createQueryBuilder: jest.fn(() => ({ - relation: jest.fn(() => ({ - of: jest.fn(() => ({ - add: jest.fn(), - remove: jest.fn(), - loadMany: jest.fn(), - })), - })), - })), - }; - - const mockLocationRepo = { - findByIds: jest.fn(), - }; + const locationDbServiceMock = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -47,75 +33,18 @@ describe('ConsumableService', () => { useValue: mockDbService, }, { - provide: getRepositoryToken(ConsumableEntity), - useValue: mockConsumableRepo, - }, - { - provide: getRepositoryToken(LocationEntity), - useValue: mockLocationRepo, + provide: LocationDbService, + useValue: locationDbServiceMock, }, ], }).compile(); service = module.get(ConsumableService); dbService = module.get(ConsumableDbService); - consumableRepo = module.get>(getRepositoryToken(ConsumableEntity)); - locationRepo = module.get>(getRepositoryToken(LocationEntity)); + locationDbService = module.get(LocationDbService); }); it('should be defined', () => { expect(service).toBeDefined(); }); - - it('should create a consumable', async () => { - const createDto: ConsumableCreateDto = { - name: 'Test Consumable', - notice: 'Test notice', - quantity: 10, - expirationDate: new Date('2025-12-31'), - groupId: 1, - locationIds: [1, 2], - }; - - const mockEntity = { - id: 1, - name: 'Test Consumable', - notice: 'Test notice', - quantity: 10, - expirationDate: new Date('2025-12-31'), - groupId: 1, - }; - - const mockLocations = [ - { id: 1, name: 'Location 1' }, - { id: 2, name: 'Location 2' }, - ]; - - mockDbService.create.mockResolvedValue(mockEntity); - mockDbService.findOne.mockResolvedValue(mockEntity); - mockLocationRepo.findByIds.mockResolvedValue(mockLocations); - - const result = await service.create(createDto); - - expect(result).toEqual(mockEntity); - expect(dbService.create).toHaveBeenCalledWith({ - name: 'Test Consumable', - notice: 'Test notice', - quantity: 10, - expirationDate: new Date('2025-12-31'), - groupId: 1, - }); - expect(dbService.findOne).toHaveBeenCalledWith(1); - expect(locationRepo.findByIds).toHaveBeenCalledWith([1, 2]); - }); - - it('should get count of consumables', async () => { - const mockCount = { count: 5 }; - mockDbService.getCount.mockResolvedValue(5); - - const result = await service.getCount(); - - expect(result).toEqual(mockCount); - expect(dbService.getCount).toHaveBeenCalledWith(undefined); - }); -}); \ No newline at end of file +}); diff --git a/backend/src/inventory/consumable/consumable.service.ts b/backend/src/inventory/consumable/consumable.service.ts index b07a1ad..1693f11 100644 --- a/backend/src/inventory/consumable/consumable.service.ts +++ b/backend/src/inventory/consumable/consumable.service.ts @@ -4,12 +4,8 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { plainToInstance } from 'class-transformer'; import { CountDto } from '../../shared/dto/count.dto'; -import { LocationEntity } from '../../base/location/location.entity'; -import { ConsumableEntity } from './consumable.entity'; import { ConsumableDbService } from './consumable-db.service'; import { ConsumableDto } from './dto/consumable.dto'; import { ConsumableCreateDto } from './dto/consumable-create.dto'; diff --git a/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts b/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts index 685215d..d008087 100644 --- a/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable-get-query.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; +import { Transform, TransformFnParams } from 'class-transformer'; import { IsIn, IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; export class ConsumableGetQueryDto { @@ -11,10 +11,11 @@ export class ConsumableGetQueryDto { @ApiProperty({ required: false, nullable: true, type: [Number] }) @IsOptional() - @Transform(({ value }) => { + @Transform(({ value }: TransformFnParams) => { if (typeof value === 'string') { - return value.split(',').map(id => parseInt(id, 10)); + return value.split(',').map((id) => parseInt(id, 10)); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }) @IsInt({ each: true }) @@ -24,13 +25,7 @@ export class ConsumableGetQueryDto { @ApiProperty({ required: false }) @IsOptional() @IsString() - @IsIn([ - 'id', - 'name', - 'quantity', - 'expirationDate', - 'group.name', - ]) + @IsIn(['id', 'name', 'quantity', 'expirationDate', 'group.name']) sortCol?: string; @ApiProperty({ required: false }) @@ -38,4 +33,4 @@ export class ConsumableGetQueryDto { @IsString() @IsIn(['ASC', 'DESC']) sortDir?: 'ASC' | 'DESC'; -} \ No newline at end of file +} diff --git a/backend/src/inventory/consumable/dto/consumable-location.dto.ts b/backend/src/inventory/consumable/dto/consumable-location.dto.ts index a00d12e..422b690 100644 --- a/backend/src/inventory/consumable/dto/consumable-location.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable-location.dto.ts @@ -11,7 +11,6 @@ import { } from 'class-validator'; import { LocationDto } from '../../../base/location/dto/location.dto'; - export class ConsumableLocationDto { @ApiProperty() @Expose() diff --git a/backend/src/inventory/device/device-db.service.ts b/backend/src/inventory/device/device-db.service.ts index 626bd08..166b137 100644 --- a/backend/src/inventory/device/device-db.service.ts +++ b/backend/src/inventory/device/device-db.service.ts @@ -49,7 +49,7 @@ export class DeviceDbService { .leftJoinAndSelect('d.group', 'dg') .leftJoinAndSelect('d.location', 'l') .leftJoinAndSelect('l.parent', 'lp') - .leftJoinAndSelect('d.images', 'i'); + .leftJoinAndSelect('d.defaultImage', 'di'); if (searchTerm) { query = this.searchQueryBuilder(query, searchTerm); @@ -99,6 +99,7 @@ export class DeviceDbService { .leftJoinAndSelect('d.location', 'l') .leftJoinAndSelect('l.parent', 'lp') .leftJoinAndSelect('d.images', 'i') + .leftJoinAndSelect('d.defaultImage', 'di') .where('d.id = :id', { id }); return query.getOne(); @@ -118,7 +119,16 @@ export class DeviceDbService { return (result.affected ?? 0) > 0; } - async addImage(device: DeviceEntity, imageId: string) { + 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 index ab51e9c..3d459cc 100644 --- a/backend/src/inventory/device/device-image.entity.ts +++ b/backend/src/inventory/device/device-image.entity.ts @@ -1,4 +1,4 @@ -import { Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; import { DeviceEntity } from './device.entity'; @Entity() @@ -6,6 +6,8 @@ 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 f6b19ed..448abda 100644 --- a/backend/src/inventory/device/device.controller.ts +++ b/backend/src/inventory/device/device.controller.ts @@ -15,6 +15,9 @@ 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 { @@ -112,4 +115,34 @@ export class DeviceController { ): 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 2c34afe..29ee088 100644 --- a/backend/src/inventory/device/device.entity.ts +++ b/backend/src/inventory/device/device.entity.ts @@ -1,4 +1,12 @@ -import { Column, Entity, ManyToOne, OneToMany, 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'; @@ -80,4 +88,14 @@ export class DeviceEntity { @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 0193051..8341090 100644 --- a/backend/src/inventory/device/device.service.ts +++ b/backend/src/inventory/device/device.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, InternalServerErrorException, NotFoundException, @@ -13,11 +15,14 @@ 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, + @Inject(forwardRef(() => MinioService)) private readonly minioService: MinioService, ) {} @@ -49,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(); } @@ -77,11 +106,7 @@ export class DeviceService { throw new NotFoundException(); } - const entity = await this.dbService.findOne(id); - if (!entity) { - throw new NotFoundException(); - } - return plainToInstance(DeviceDto, entity); + return this.findOne(id); } public async getImageUploadUrl(id: number, contentType: string) { @@ -102,9 +127,11 @@ export class DeviceService { contentType, 50, // 50 MB ); - return plainToInstance(UploadUrlDto, url, { - excludeExtraneousValues: true, - }); + return plainToInstance( + UploadUrlDto, + { ...url, id: uuid }, + { excludeExtraneousValues: true }, + ); } async addImage(deviceId: number, imageId: string) { @@ -120,4 +147,23 @@ export class DeviceService { 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 index 1bee3e6..c947c0f 100644 --- a/backend/src/inventory/device/dto/device-image.dto.ts +++ b/backend/src/inventory/device/dto/device-image.dto.ts @@ -5,4 +5,8 @@ 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 2685e64..255a259 100644 --- a/backend/src/inventory/device/dto/device.dto.ts +++ b/backend/src/inventory/device/dto/device.dto.ts @@ -10,6 +10,7 @@ import { IsOptional, IsPositive, IsString, + IsUUID, MaxLength, } from 'class-validator'; import { LocationDto } from '../../../base/location/dto/location.dto'; @@ -149,8 +150,20 @@ export class DeviceDto { @Type(() => LocationDto) location?: LocationDto; - @ApiProperty({ required: false, nullable: true }) + @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/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 index 5cfb76a..332bda1 100644 --- a/backend/src/shared/dto/filename.dto.ts +++ b/backend/src/shared/dto/filename.dto.ts @@ -2,7 +2,6 @@ import { IsDefined, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class FilenameDto { - @ApiProperty() @IsDefined() @IsString() 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 index 5c6d458..d772a85 100644 --- a/backend/src/shared/dto/upload-url.dto.ts +++ b/backend/src/shared/dto/upload-url.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Expose, Transform, Type } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; class FormData { @ApiProperty() @@ -43,5 +43,9 @@ export class UploadUrlDto { @ApiProperty() @Expose() @Type(() => FormData) - formData: { [key: string]: any }; + 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/docker-compose.dev.yml b/docker-compose.dev.yml index 5cb97ea..95d9107 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -33,6 +33,7 @@ services: - MINIO_NOTIFY_AMQP_QUEUE_LIMIT_AMQP=10000 volumes: - minio:/data + - ./docker/certs:/root/.minio/certs command: server /data --console-address ":9001" networks: - queue 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/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 fae3d25..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 @@ -233,6 +233,34 @@

    Das Gerät wurde nicht gefunden!

    +
    +
    + + + + + +
      +
    • + +
    • +
    +
    +
    + + example + +
    +
    ; @@ -58,17 +65,24 @@ interface DeviceDetailForm { 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(); @@ -125,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; @@ -137,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(); @@ -216,41 +232,23 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { uploadImage(item: NzUploadXHRArgs): Subscription { const comp = (item.data as DeviceDetailComponent); - return comp.service.uploadImage(item.file.name, item); + return comp.service.uploadImage('test', item); } imageChanged(event: NzUploadChangeParam) { - // TODO Device neu laden und Bilder aktualisieren - // Upload event handling, wenn sich etwas ändert if (event.type === "success") { let i = 0; - let pollInterval = 500; + const pollInterval = 500; interval(pollInterval) .pipe( - mergeMap(async () => { - //await this.equipmentService.findOne(this.entity.id); - }), - map(x => { - i++; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filename = (event.file.originFileObj as any)?.filename ?? ''; - /*if (x.documents.find(y => y.key === filename) !== undefined) { - // this.entity.documents = x.documents; - const i = this.imgList.findIndex(y => y.name === event.file.name); - if (i !== -1) { - this.imgList = this.imgList.slice(i, -1); - } - return true; - }*/ - console.log(i); - return false; - }), - takeWhile(x => !x && i < 10 / (pollInterval / 1000)), + 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!`); + this.notification.create("success", "Hochgeladen", `Datei wurde erfolgreich hochgeladen!`); } }); } else if (event.type === 'error') { @@ -258,7 +256,19 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { if (index !== -1) { this.imgList.splice(index, 1); } - // this.notification.create("error", "Fehler", "Fehler beim hochladen"); + 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 dfb387e..a26426d 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 @@ -6,11 +6,9 @@ import { HttpClient, HttpErrorResponse, HttpEventType, - HttpHeaders, - HttpRequest, HttpResponse } from '@angular/common/http'; -import {BehaviorSubject, debounceTime, filter, mergeMap, of, Subject, switchMap} from 'rxjs'; +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'; @@ -52,6 +50,8 @@ export class DeviceDetailService { locations = signal([]); locationsIsLoading = signal(false); + uploadIds: { imageId: string, fileId: string }[] = []; + constructor( private readonly apiService: DeviceService, private readonly apiDeviceTypesService: DeviceTypeService, @@ -138,6 +138,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) { @@ -232,6 +251,7 @@ export class DeviceDetailService { 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); @@ -260,4 +280,54 @@ export class DeviceDetailService { } }); } + + 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/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 index a82c051..179602f 100644 --- 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 @@ -7,7 +7,7 @@ [nzShowUploadList]="{showRemoveIcon: false, showPreviewIcon: false, showDownloadIcon: false}" [nzData]="nzData" (nzChange)="nzChange.emit($event)" - nzAccept="image/*"> + nzAccept="image/jpeg, image/png, image/tiff">

    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 f458676..09b96b8 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -1048,7 +1048,6 @@ paths: summary: '' tags: - Device -<<<<<<< HEAD /api/device/{id}/image-upload: get: description: Gibt die URL zum Hochladen eines Gerätebildes zurück @@ -1080,57 +1079,23 @@ paths: summary: '' tags: - Device - /api/location/count: - get: - description: Gibt die Anzahl aller Standorte zurück - operationId: LocationController_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: - - Location - /api/location: + /api/device/{id}/image/{imageId}: get: - description: Gibt alle Standorte zurück - operationId: LocationController_getAll + description: Gibt die Url zum herunterladen des Gerätebild zurück. + operationId: DeviceController_downloadImage parameters: - - name: limit - required: false - in: query - schema: - type: number - - name: offset - required: false - in: query + - name: id + required: true + in: path schema: type: number - - name: sortCol - required: false - in: query - schema: - type: string - - name: sortDir - required: false - in: query + - name: imageId + required: true + in: path schema: type: string - - name: searchTerm - required: false + - name: size + required: true in: query schema: type: string @@ -1140,59 +1105,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/LocationDto' - '401': - description: '' - security: - - bearer: [] - summary: '' - tags: - - Location - post: - description: Erstellt einen Standort - operationId: LocationController_create - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LocationCreateDto' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/LocationDto' - '401': - description: '' - '404': - description: '' - security: - - bearer: [] - summary: '' - tags: - - Location - /api/location/{id}: - get: - description: Gibt einen Standort zurück - operationId: LocationController_getOne - parameters: - - name: id - required: true - in: path - schema: - type: number - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/LocationDto' + $ref: '#/components/schemas/DownloadUrlDto' '401': description: '' '404': @@ -1201,47 +1114,21 @@ paths: - bearer: [] summary: '' tags: - - Location - put: - description: Aktualisiert einen Standort - operationId: LocationController_update + - Device + delete: + description: Löscht ein Bild + operationId: DeviceController_deleteImage parameters: - name: id required: true in: path schema: type: number - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LocationUpdateDto' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/LocationDto' - '401': - description: '' - '404': - description: '' - security: - - bearer: [] - summary: '' - tags: - - Location - delete: - description: Löscht einen Standort - operationId: LocationController_delete - parameters: - - name: id + - name: imageId required: true in: path schema: - type: number + type: string responses: '204': description: '' @@ -1253,9 +1140,7 @@ paths: - bearer: [] summary: '' tags: - - Location -======= ->>>>>>> 95edcf7 (small fixes) + - Device /api/consumable-group/count: get: description: Gibt die Anzahl aller Verbrauchsgüter-Gruppen zurück @@ -1621,7 +1506,7 @@ paths: summary: '' tags: - Consumable - /api/consumable/{id}/locations/{locationId}: + /api/consumable/{id}/locations: post: description: Fügt einem Verbrauchsgut einen Standort hinzu operationId: ConsumableController_addLocation @@ -1631,7 +1516,39 @@ paths: in: path schema: type: number - - name: locationId + 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: @@ -1652,20 +1569,28 @@ paths: summary: '' tags: - Consumable - delete: - description: Entfernt einen Standort von einem Verbrauchsgut - operationId: ConsumableController_removeLocation + 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: locationId + - name: relationId required: true in: path schema: type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumableLocationUpdateDto' responses: '200': description: '' @@ -2151,6 +2076,16 @@ components: - id - name - type + DeviceImageDto: + type: object + properties: + id: + type: string + previewUrl: + type: string + required: + - id + - previewUrl DeviceDto: type: object properties: @@ -2219,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 @@ -2273,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: @@ -2337,20 +2276,14 @@ components: groupId: type: number nullable: true - group: - nullable: true - allOf: - - $ref: '#/components/schemas/DeviceTypeDto' locationId: type: number nullable: true - location: + defaultImageId: + type: string nullable: true - allOf: - - $ref: '#/components/schemas/LocationDto' required: - state -<<<<<<< HEAD UploadUrlDto: type: object properties: @@ -2358,10 +2291,19 @@ components: type: string formData: type: object + id: + type: string required: - postURL - formData -======= + - id + DownloadUrlDto: + type: object + properties: + url: + type: string + required: + - url ConsumableGroupDto: type: object properties: @@ -2393,14 +2335,11 @@ components: notice: type: string nullable: true - ConsumableDto: + ConsumableLocationDto: type: object properties: id: type: number - name: - type: string - nullable: true notice: type: string nullable: true @@ -2410,6 +2349,27 @@ components: format: date-time type: string nullable: true + locationId: + type: number + nullable: true + location: + nullable: true + allOf: + - $ref: '#/components/schemas/LocationDto' + required: + - id + - quantity + ConsumableDto: + type: object + properties: + id: + type: number + name: + type: string + nullable: true + notice: + type: string + nullable: true groupId: type: number nullable: true @@ -2417,25 +2377,45 @@ components: nullable: true allOf: - $ref: '#/components/schemas/ConsumableGroupDto' - locationIds: + consumableLocationIds: nullable: true type: array items: type: string - locations: + consumableLocations: nullable: true type: array items: - $ref: '#/components/schemas/LocationDto' + $ref: '#/components/schemas/ConsumableLocationDto' required: - id - - quantity 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 @@ -2445,22 +2425,14 @@ components: format: date-time type: string nullable: true - groupId: + locationId: type: number nullable: true - locationIds: - nullable: true - type: array - items: - type: string required: - quantity - ConsumableUpdateDto: + ConsumableLocationUpdateDto: type: object properties: - name: - type: string - nullable: true notice: type: string nullable: true @@ -2470,15 +2442,11 @@ components: format: date-time type: string nullable: true - groupId: + locationId: type: number nullable: true - locationIds: - nullable: true - type: array - items: - type: string ->>>>>>> 95edcf7 (small fixes) + required: + - quantity LocationCreateDto: type: object properties: From 2ed223b93665910dcacb1be5dade7ac650952ac6 Mon Sep 17 00:00:00 2001 From: Philipp von Kirschbaum <2657033+KirschbaumP@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:51:05 +0200 Subject: [PATCH 16/16] =?UTF-8?q?=E2=9C=85=20Verbrauchsmaterial=20mit=20St?= =?UTF-8?q?andorten=20verkn=C3=BCpfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/base/location/location.entity.ts | 2 - .../storage/minio-listener.service.ts | 2 +- .../consumable/consumable.service.ts | 2 +- .../consumable/dto/consumable-location.dto.ts | 6 +- frontend/src/app/app.config.ts | 7 +- .../consumable-detail.component.html | 186 ++++++++++++++---- .../consumable-detail.component.ts | 116 ++++++++--- .../consumable-detail.service.ts | 134 +++++++++++-- openapi/backend.yml | 44 ++++- 9 files changed, 412 insertions(+), 87 deletions(-) diff --git a/backend/src/base/location/location.entity.ts b/backend/src/base/location/location.entity.ts index 025dcba..8d6a5d1 100644 --- a/backend/src/base/location/location.entity.ts +++ b/backend/src/base/location/location.entity.ts @@ -1,7 +1,6 @@ import { Column, Entity, - ManyToMany, OneToMany, PrimaryGeneratedColumn, Tree, @@ -9,7 +8,6 @@ import { TreeParent, } from 'typeorm'; import { DeviceEntity } from '../../inventory/device/device.entity'; -import { ConsumableEntity } from '../../inventory/consumable/consumable.entity'; import { ConsumableLocationEntity } from '../../inventory/consumable/consumable-location.entity'; export enum LocationType { diff --git a/backend/src/core/services/storage/minio-listener.service.ts b/backend/src/core/services/storage/minio-listener.service.ts index 636902b..40ad545 100644 --- a/backend/src/core/services/storage/minio-listener.service.ts +++ b/backend/src/core/services/storage/minio-listener.service.ts @@ -61,7 +61,7 @@ export class MinioListenerService implements OnApplicationBootstrap { ): Promise { const key = decodeURIComponent(bucketObject.key); const imageType = this.imageRegex.test(key); - const docType = this.docRegex.test(key); + // const docType = this.docRegex.test(key); if (key.startsWith('devices/')) { if (imageType) { diff --git a/backend/src/inventory/consumable/consumable.service.ts b/backend/src/inventory/consumable/consumable.service.ts index 1693f11..41fa510 100644 --- a/backend/src/inventory/consumable/consumable.service.ts +++ b/backend/src/inventory/consumable/consumable.service.ts @@ -123,7 +123,7 @@ export class ConsumableService { data: ConsumableLocationUpdateDto, ) { // Check if relation exists - const relation = await this.dbService.findLocationRelation(id, relationId); + const relation = await this.dbService.findLocationRelation(relationId, id); if (!relation) { throw new NotFoundException('Relation not found'); } diff --git a/backend/src/inventory/consumable/dto/consumable-location.dto.ts b/backend/src/inventory/consumable/dto/consumable-location.dto.ts index 422b690..4f86125 100644 --- a/backend/src/inventory/consumable/dto/consumable-location.dto.ts +++ b/backend/src/inventory/consumable/dto/consumable-location.dto.ts @@ -40,14 +40,14 @@ export class ConsumableLocationDto { @IsDate() expirationDate?: Date; - @ApiProperty({ required: false, nullable: true }) + @ApiProperty({ required: true, nullable: false }) @Expose() - @IsOptional() + @IsDefined() @IsInt() @IsPositive() locationId: number; - @ApiProperty({ required: false, nullable: true, type: LocationDto }) + @ApiProperty({ type: LocationDto }) @Expose() @Type(() => LocationDto) location?: LocationDto; 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/pages/inventory/consumables/consumable-detail/consumable-detail.component.html b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.component.html index d2e4f65..7a6a6b4 100644 --- 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 @@ -6,42 +6,131 @@

    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. - } - - - + + + + + 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 }} + + + + + + + + +
    +
    + + + +
    - Verbrauchsmaterial-Gruppe + Standort/Fahrzeug - @if (consumableGroupsIsLoading()) { - - - Laden... - + @if (locationsIsLoading()) { + } @else { - @for (item of consumableGroups(); track item) { + @for (item of locations(); track item) { } } @@ -49,6 +138,30 @@

    Das Verbrauchsmaterial wurde nicht gefunden!

    + + Anzahl + + + + @if (control.errors?.['required']) { + Bitte eine Anzahl eingeben. + } + + + + + + Ablaufdatum + + + + @if (control.errors?.['required']) { + Bitte eine Anzahl eingeben. + } + + + + Weitere Informationen @@ -61,14 +174,11 @@

    Das Verbrauchsmaterial wurde nicht gefunden!

    - - - - - - -
    - + + + + + +
    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 index e5f17e8..cd83a99 100644 --- 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 @@ -1,11 +1,10 @@ import {Component, effect, OnDestroy, OnInit, Signal} from '@angular/core'; -import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +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 {InAppMessageService} from '../../../../shared/services/in-app-message.service'; import {ConsumableDetailService} from './consumable-detail.service'; -import {NgIf} from '@angular/common'; +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'; @@ -16,6 +15,12 @@ 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; @@ -23,6 +28,13 @@ interface ConsumableUpdateForm { groupId: FormControl; } +interface ConsumableLocationForm { + locationId: FormControl; + quantity: FormControl; + expirationDate: FormControl; + notice: FormControl; +} + @Component({ selector: 'ofs-consumable-detail', imports: [ @@ -37,8 +49,14 @@ interface ConsumableUpdateForm { 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 { @@ -62,14 +80,34 @@ export class ConsumableDetailComponent implements OnInit, OnDestroy { }), }); + 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, - private readonly inAppMessagingService: InAppMessageService, ) { + this.entity = this.service.entity; this.notFound = this.service.notFound; this.loading = this.service.loading; this.loadingError = this.service.loadingError; @@ -78,30 +116,21 @@ export class ConsumableDetailComponent implements OnInit, OnDestroy { 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); + if (entity) { + this.form.patchValue(entity as any); + } }); - effect(() => { - const updateLoading = this.service.loadingError(); - if (updateLoading) { - this.inAppMessagingService.showError('Fehler beim laden des Geräts.'); - } + this.service.resetLocationForm(); + this.formLocation.reset(); }); - this.service.deleteLoadingError - .pipe(takeUntil(this.destroy$)) - .subscribe((x) => this.inAppMessagingService.showError(x)); - this.service.deleteLoadingSuccess - .pipe(takeUntil(this.destroy$)) - .subscribe(() => this.inAppMessagingService.showSuccess('Gerät 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 { @@ -128,4 +157,47 @@ export class ConsumableDetailComponent implements OnInit, OnDestroy { 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.ts b/frontend/src/app/pages/inventory/consumables/consumable-detail/consumable-detail.service.ts index b87c412..a820b11 100644 --- 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 @@ -1,4 +1,4 @@ -import {Inject, Injectable, signal} from '@angular/core'; +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'; @@ -8,6 +8,11 @@ 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' @@ -20,25 +25,31 @@ export class ConsumableDetailService { notFound = signal(false); deleteLoading = signal(false); updateLoading = signal(false); - updateLoadingError = new Subject(); - deleteLoadingError = new Subject(); - updateLoadingSuccess = new Subject(); - deleteLoadingSuccess = new Subject(); + 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), @@ -46,6 +57,13 @@ export class ConsumableDetailService { this.consumableGroupsSearch = x.value; this.loadMoreGroups(); }); + this.locationSearch$.pipe( + filter(x => x.propagate), + debounceTime(time), + ).subscribe((x) => { + this.locationSearch = x.value; + this.loadMoreLocations(); + }); } @@ -69,7 +87,7 @@ export class ConsumableDetailService { this.notFound.set(true); } this.entity.set(null); - this.loadingError.set(true); + this.inAppMessagingService.showError('Fehler beim laden des Geräts.'); this.loading.set(false); } }); @@ -84,11 +102,11 @@ export class ConsumableDetailService { next: (newEntity) => { this.updateLoading.set(false); this.entity.set(newEntity); - this.updateLoadingSuccess.next(); + this.inAppMessagingService.showSuccess('Gerät gelöscht'); }, error: () => { this.updateLoading.set(false); - this.updateLoadingError.next(); + this.inAppMessagingService.showError('Fehler beim speichern.'); }, }); } @@ -102,12 +120,12 @@ export class ConsumableDetailService { .subscribe({ next: () => { this.deleteLoading.set(false); - this.deleteLoadingSuccess.next(); + this.inAppMessagingService.showInfo('Material wurde gelöscht.') this.router.navigate(['inventory', 'consumables']); }, error: (err: HttpErrorResponse) => { this.deleteLoading.set(false); - this.deleteLoadingError.next(err.status === 400 ? err.error : 'Fehler beim löschen'); + this.inAppMessagingService.showError(err.status === 400 ? err.error : 'Fehler beim löschen'); }, }); } @@ -132,7 +150,101 @@ export class ConsumableDetailService { }); } + 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/openapi/backend.yml b/openapi/backend.yml index 09b96b8..f0ca06d 100644 --- a/openapi/backend.yml +++ b/openapi/backend.yml @@ -2284,13 +2284,41 @@ components: 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: - type: object + $ref: '#/components/schemas/FormData' id: type: string required: @@ -2351,14 +2379,14 @@ components: nullable: true locationId: type: number - nullable: true + nullable: false location: - nullable: true - allOf: - - $ref: '#/components/schemas/LocationDto' + $ref: '#/components/schemas/LocationDto' required: - id - quantity + - locationId + - location ConsumableDto: type: object properties: @@ -2427,9 +2455,10 @@ components: nullable: true locationId: type: number - nullable: true + nullable: false required: - quantity + - locationId ConsumableLocationUpdateDto: type: object properties: @@ -2444,9 +2473,10 @@ components: nullable: true locationId: type: number - nullable: true + nullable: false required: - quantity + - locationId LocationCreateDto: type: object properties: