Compare commits

..

40 Commits

Author SHA1 Message Date
Bastian Wagner
93053e0101 Change Detection bei Schlüsseländerungen 2026-03-12 09:37:46 +01:00
Bastian Wagner
ccbdc7cefa Settings repariert 2026-03-12 09:33:48 +01:00
Bastian Wagner
0a7285c6c3 unit test 2026-03-11 14:46:36 +01:00
Bastian Wagner
5a15847c4a Unit Tests Frontend 2026-03-11 14:41:57 +01:00
Bastian Wagner
1480e8d7b2 Unregister & Unsubscribe vom SSE für Keys eingebaut 2026-03-09 12:59:59 +01:00
Bastian Wagner
b3fd7fbf03 Unit tests 2026-03-09 10:54:50 +01:00
Bastian Wagner
ac2117b64b Keys auf SSE umgestellt 2026-03-05 14:51:45 +01:00
Bastian Wagner
f88fe93182 styling 2026-03-05 10:34:10 +01:00
Bastian Wagner
020216026e Einstellungen auf Dialog umgebaut 2026-03-05 10:13:41 +01:00
Bastian Wagner
026e47cd1b docs 2026-02-27 15:11:12 +01:00
Bastian Wagner
f1680ae07a bump 2026-02-27 11:32:06 +01:00
Bastian Wagner
f86c9c681a Unit tests 2026-02-27 11:03:35 +01:00
Bastian Wagner
5aa97cd8ea cleaning 2026-02-25 16:01:06 +01:00
Bastian Wagner
f15df81fed hidden columns 2026-02-25 14:17:36 +01:00
Bastian Wagner
53fa657099 pwa name angepasst 2026-02-25 10:29:50 +01:00
Bastian Wagner
d9f633deef zylinder entity 2026-02-25 10:28:24 +01:00
Bastian Wagner
447ac5d6ca cylinder entity umbenannnt 2026-02-24 15:04:31 +01:00
Bastian Wagner
f7e9ee493b Digitale Zylinder eingebaut 2026-02-24 14:55:19 +01:00
Bastian Wagner
e5c590165c schlüssel neuen zylindern zuordnen geht 2026-02-22 17:08:50 +01:00
Bastian Wagner
6797b73eb1 Schließanlagen können gelöscht werden 2026-02-20 16:44:59 +01:00
Bastian Wagner
955faa5cd5 styling 2026-02-20 13:52:19 +01:00
Bastian Wagner
62520466dc Impersination backend 2026-02-20 13:17:58 +01:00
Bastian Wagner
affea90e91 Renaming 2026-02-20 10:39:11 +01:00
Bastian Wagner
4e051a1f40 Logging und sowas 2026-02-20 10:28:48 +01:00
Bastian Wagner
29bfffc505 fixes 2026-02-19 22:29:46 +01:00
Bastian Wagner
4df51e0698 Filter Styling 2026-02-19 17:34:53 +01:00
Bastian Wagner
c542575046 Wording und API 2026-02-19 17:19:03 +01:00
Bastian Wagner
7bd6dfae27 Archive und Logging 2026-02-19 16:19:46 +01:00
Bastian Wagner
ef45e91141 Ag Grid anpassungen 2026-02-19 12:21:30 +01:00
Bastian Wagner
d7cfc89ba5 pwa icon 2026-02-18 14:14:15 +01:00
Bastian Wagner
0fd4967c44 pwa support 2026-02-18 14:02:45 +01:00
Bastian Wagner
dd59a62e96 Api umgebaut 2026-02-18 13:56:29 +01:00
Bastian Wagner
40e3ac187e queryparams aus url 2026-02-17 15:38:38 +01:00
Bastian Wagner
a292b29cb1 html angepasst 2026-02-17 13:46:12 +01:00
Bastian Wagner
df41dda7dc refactor 2026-02-17 10:56:08 +01:00
Bastian Wagner
eb5d9dd088 Ui
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 15:13:05 +01:00
Bastian Wagner
8545ef3b36 docker
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 15:03:47 +01:00
Bastian Wagner
57c1faa3ba docker
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 14:52:15 +01:00
Bastian Wagner
e5bad1163b Docker
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 14:42:55 +01:00
Bastian Wagner
62e7431112 typo
Some checks failed
Run Unit-Tests / test_frontend (push) Has been cancelled
Run Unit-Tests / test_backend (push) Has been cancelled
2026-02-16 14:35:08 +01:00
136 changed files with 6188 additions and 4410 deletions

View File

@@ -1,41 +0,0 @@
name: Docker Image CI for GHCR
on:
workflow_run:
workflows: ["Run Unit-Tests"]
types:
- completed
branches:
- master
jobs:
build_and_publish_backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build and Push Image
run: |
docker login --username wagnerbastian --password ${{ secrets.GH_PAT }} ghcr.io
docker build ./api --tag ghcr.io/wagnerbastian/keyvault_pro_api:latest
docker push ghcr.io/wagnerbastian/keyvault_pro_api:latest
build_and_publish_frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build and Push Image
run: |
docker login --username wagnerbastian --password ${{ secrets.GH_PAT }} ghcr.io
docker build ./client --tag ghcr.io/wagnerbastian/keyvault_pro_client:latest
docker push ghcr.io/wagnerbastian/keyvault_pro_client:latest
ssh-login-and-publish:
runs-on: ubuntu-latest
needs: [build_and_publish_frontend, build_and_publish_backend]
steps:
- name: Setup SSH Keys and known_hosts
run: |
install -m 600 -D /dev/null ~/.ssh/id_rsa
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_HOST }} > ~/.ssh/known_hosts
- name: connect and pull
run: |
ssh ${{ secrets.SERVER_USERNAME }}@${{ secrets.SERVER_HOST }} "cd docker/keyvault && docker stop keyvault_client || true && docker rm keyvault_client || true && docker stop keyvault_pro_api || true && docker rm keyvault_pro_api || true && docker-compose pull && docker-compose up -d"

View File

@@ -1,26 +0,0 @@
name: Run Unit-Tests
on:
push:
branches: ["*"] # Alle Branches
tags: ["*"] # Alle Tags
jobs:
test_frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Unit-Tests Frontend
run: |
cd client
npm install
npm run test
test_backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Unit-Tests Backend
run: |
cd api
npm install
npm run test

View File

@@ -1,31 +1,27 @@
FROM node:18 AS development # -------- BUILD --------
WORKDIR /api/src FROM node:22 AS build
WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
################ # -------- PROD DEPS (nur prod node_modules) --------
## PRODUCTION ## FROM node:22 AS deps
################ WORKDIR /app
# Build another image named production
FROM node:18 AS production
# Set node env to prod COPY package*.json ./
ARG NODE_ENV=production RUN npm ci --omit=dev && npm cache clean --force
ENV NODE_ENV=${NODE_ENV}
# Set Working Directory # -------- RUNTIME --------
WORKDIR /api/src FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Copy all from development stage COPY --from=deps /app/node_modules ./node_modules
COPY --from=development /api/src/ . COPY --from=build /app/dist ./dist
COPY package*.json ./
EXPOSE 4000 EXPOSE 4000
CMD ["node", "dist/src/main"]
# Run app
CMD [ "node", "dist/src/main" ]
# Example Commands to build and run the dockerfile
# docker build -t thomas-nest .
# docker run thomas-nest

2
api/mocks/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './repositories';
export * from './services';

View File

@@ -0,0 +1,2 @@
export * from './system.repository.mock';
export * from './user.repository.mock';

View File

@@ -0,0 +1,41 @@
import { KeySystem } from "src/model/entitites/system.entity";
import { CreateSystemDto } from "src/modules/system/dto/create-system.dto";
export class MockKeySystemRepository {
create = jest.fn().mockImplementation((register: CreateSystemDto) => {
const x = new KeySystem();
x.name = register.name;
return x;
});
save = jest.fn().mockImplementation((system: KeySystem) => {
system.id = '1234';
system.createdAt = new Date();
return Promise.resolve(system);
});
softRemove = jest.fn().mockImplementation((system: KeySystem) => {
system.deletedAt = new Date();
return Promise.resolve(system);
});
findOne = jest.fn().mockImplementation(() => {
const system = this.createKeySystem();
return system;
})
findOneOrFail = jest.fn().mockImplementation(() => {
const system = this.createKeySystem();
return system;
})
private createKeySystem(): KeySystem {
const s = new KeySystem();
s.id = '1234';
s.name = 'Testname1234';
s.createdAt = new Date();
return s;
}
}

View File

@@ -0,0 +1 @@
export * from './mail.service.mock';

View File

@@ -0,0 +1,3 @@
export class MockMailService {
}

6181
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,39 +20,39 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
}, },
"dependencies": { "dependencies": {
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "2.0.2",
"@nestjs/axios": "^3.0.3", "@nestjs/axios": "4.0.1",
"@nestjs/cache-manager": "^2.3.0", "@nestjs/cache-manager": "3.1.0",
"@nestjs/common": "^10.0.0", "@nestjs/common": "11.1.14",
"@nestjs/config": "^3.2.3", "@nestjs/config": "4.0.3",
"@nestjs/core": "^10.0.0", "@nestjs/core": "11.1.14",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "^2.0.5", "@nestjs/mapped-types": "2.1.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "11.1.14",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "11.0.0",
"axios": "^1.7.7", "axios": "1.13.5",
"cache-manager": "^5.7.6", "cache-manager": "7.2.8",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "0.14.1",
"mysql2": "^3.11.2", "mysql2": "3.18.2",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "7.8.2",
"typeorm": "^0.3.20" "typeorm": "0.3.28"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^9.0.0", "@faker-js/faker": "^9.0.0",
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "11.0.16",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "11.0.9",
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "11.1.14",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "8.56.1",
"eslint": "^8.42.0", "eslint": "10.0.2",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "5.5.5",
"jest": "^29.5.0", "jest": "^29.5.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",

View File

@@ -15,6 +15,7 @@ import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { APP_INTERCEPTOR } from '@nestjs/core'; import { APP_INTERCEPTOR } from '@nestjs/core';
import { MailModule } from './modules/mail/mail.module'; import { MailModule } from './modules/mail/mail.module';
import { LogModule } from './modules/log/log.module'; import { LogModule } from './modules/log/log.module';
import { SseModule } from './modules/realtime/sse/sse.module';
@Module({ @Module({
imports: [ imports: [
@@ -22,7 +23,7 @@ import { LogModule } from './modules/log/log.module';
envFilePath: ['.env'], envFilePath: ['.env'],
isGlobal: true, isGlobal: true,
}), }),
CacheModule.register({ ttl: 5000, isGlobal: true }), // CacheModule.register({ ttl: 1000, isGlobal: true }),
DatabaseModule, DatabaseModule,
AuthModule, AuthModule,
UserModule, UserModule,
@@ -33,15 +34,16 @@ import { LogModule } from './modules/log/log.module';
SystemModule, SystemModule,
MailModule, MailModule,
LogModule, LogModule,
SseModule
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
AppService, AppService,
AuthGuard, AuthGuard,
{ // {
provide: APP_INTERCEPTOR, // provide: APP_INTERCEPTOR,
useClass: CacheInterceptor, // useClass: CacheInterceptor,
}, // },
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppService } from './app.service';
describe('AppService', () => {
let service: AppService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AppService],
}).compile();
service = module.get<AppService>(AppService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -35,7 +35,7 @@ export class AuthGuard implements CanActivate {
if (payload.type != 'access') { if (payload.type != 'access') {
throw new UnauthorizedException('wrong token'); throw new UnauthorizedException('wrong token');
} }
const user = await this.authService.getUserById(payload.id); const user = await this.authService.getUserById(payload.id, true);
if (!user.isActive) { if (!user.isActive) {
throw new HttpException('not active', HttpStatus.FORBIDDEN); throw new HttpException('not active', HttpStatus.FORBIDDEN);
} }

View File

@@ -6,7 +6,6 @@ import {
Entity, Entity,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
@@ -18,9 +17,16 @@ export class Cylinder {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ nullable: false, unique: true }) @Column({ nullable: false })
name: string; name: string;
@Column({ name:'description', type: 'text', nullable: true })
description: string;
@Column({name: 'digital', type: 'boolean', default: false})
digital: boolean;
@ManyToMany(() => Key, (key) => key.cylinder, { onDelete: 'NO ACTION'}) @ManyToMany(() => Key, (key) => key.cylinder, { onDelete: 'NO ACTION'})
keys: Key[]; keys: Key[];

View File

@@ -0,0 +1,22 @@
import { Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Column } from "typeorm";
import { User } from "./user/user.entity";
@Entity()
export class Impersonation {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => User, { nullable: false, eager: true })
@JoinColumn({ name: 'fromUserId' })
fromUser: User;
@ManyToOne(() => User, { nullable: false, eager: true })
@JoinColumn({ name: 'toUserId' })
toUser: User;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
startedAt: Date;
@Column({ type: 'datetime', nullable: true })
endedAt?: Date;
}

View File

@@ -34,6 +34,9 @@ export class EmailLog {
@Column({type: 'boolean'}) @Column({type: 'boolean'})
success: boolean; success: boolean;
@Column({type: 'text', default: null})
context: boolean;
@AfterLoad() @AfterLoad()
setType() { setType() {
this.eventName = EmailEvent[this.type] this.eventName = EmailEvent[this.type]

View File

@@ -0,0 +1,21 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { User } from "./user";
@Entity()
export class MailFracture {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn({name: 'created_at'})
created: Date;
@ManyToOne(() => User)
@JoinColumn()
to: User;
@Column({ name: 'text' })
mailText: string
@Column({ name: 'sended_date' })
sended: Date
}

View File

@@ -1,6 +1,7 @@
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
DeleteDateColumn,
Entity, Entity,
JoinTable, JoinTable,
ManyToMany, ManyToMany,
@@ -32,4 +33,7 @@ export class KeySystem implements IKeySystem {
@UpdateDateColumn({ name: 'updatet_at' }) @UpdateDateColumn({ name: 'updatet_at' })
updatedAt: Date; updatedAt: Date;
@DeleteDateColumn()
deletedAt: Date;
} }

View File

@@ -58,7 +58,7 @@ export class User implements IUser {
@DeleteDateColumn() @DeleteDateColumn()
deletedAt: Date; deletedAt: Date;
@OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION', }) @OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, onUpdate: 'NO ACTION', })
settings: UserSettings; settings: UserSettings;
accessToken?: string; accessToken?: string;

View File

@@ -6,7 +6,7 @@ export class UserSettings {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@OneToOne(() => User, (user) => user.settings) @OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' })
@JoinColumn() @JoinColumn()
user: User; user: User;
@@ -20,6 +20,9 @@ export class UserSettings {
@Column({ name: 'send_system_update_notification', default: true, type: 'boolean'}) @Column({ name: 'send_system_update_notification', default: true, type: 'boolean'})
sendSystemUpdateMails: boolean; sendSystemUpdateMails: boolean;
@Column({ name: 'ui_scale', default: 'm' })
uiScale: 's' | 'm' | 'l';
} }

View File

@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Repository, DataSource } from 'typeorm';
import { Impersonation } from '../entitites/impersination.entity';
@Injectable()
export class ImpersonationRepository extends Repository<Impersonation> {
constructor(dataSource: DataSource) {
super(Impersonation, dataSource.createEntityManager());
}
}

View File

@@ -12,6 +12,7 @@ import { AuthService } from './auth.service';
import { AuthCodeDto } from 'src/model/dto'; import { AuthCodeDto } from 'src/model/dto';
import { User } from 'src/model/entitites'; import { User } from 'src/model/entitites';
import { AuthGuard } from 'src/core/guards/auth.guard'; import { AuthGuard } from 'src/core/guards/auth.guard';
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@@ -30,7 +31,7 @@ export class AuthController {
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@Get('me') @Get('me')
getMe(@Req() req: any) { getMe(@Req() req: AuthenticatedRequest) {
return req.user; return req.user;
} }

View File

@@ -27,11 +27,11 @@ describe('AuthService', () => {
}); });
it('should store a user on creation', async () => { it('should store a user on creation', async () => {
const user = await service.register({externalId: '123', username: 'sc'}); // const user = await service.register({externalId: '123', username: 'sc'});
expect(service['userRepo'].createUser).toHaveBeenCalled(); // expect(service['userRepo'].createUser).toHaveBeenCalled();
expect(user.external.externalId).toEqual('123'); // expect(user.external.externalId).toEqual('123');
expect(user.username).toEqual('sc'); // expect(user.username).toEqual('sc');
expect(1).toBe(1)
}) })
}); });

View File

@@ -8,11 +8,14 @@ import { JwtService } from '@nestjs/jwt';
import { IExternalAccessPayload, IPayload } from 'src/model/interface'; import { IExternalAccessPayload, IPayload } from 'src/model/interface';
import { User } from 'src/model/entitites'; import { User } from 'src/model/entitites';
import { LogService, LogType } from '../log/log.service'; import { LogService, LogType } from '../log/log.service';
import { ImpersonationRepository } from 'src/model/repositories/impersination.repository';
import { IsNull } from 'typeorm';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private userRepo: UserRepository, private userRepo: UserRepository,
private impersinationRepo: ImpersonationRepository,
private readonly http: HttpService, private readonly http: HttpService,
private configService: ConfigService, private configService: ConfigService,
private jwt: JwtService, private jwt: JwtService,
@@ -55,7 +58,6 @@ export class AuthService {
const payload: IExternalAccessPayload = this.jwt.decode(access_token); const payload: IExternalAccessPayload = this.jwt.decode(access_token);
return new Promise<User>(async (resolve) => { return new Promise<User>(async (resolve) => {
let user = await this.userRepo.findByUsername(payload.username, { settings: true }); let user = await this.userRepo.findByUsername(payload.username, { settings: true });
if (!user) { if (!user) {
user = await this.userRepo.createUser({ user = await this.userRepo.createUser({
username: payload.username, username: payload.username,
@@ -120,9 +122,21 @@ export class AuthService {
return bodyFormData; return bodyFormData;
} }
getUserById(id: string): Promise<User> { async getUserById(id: string, withImpersination = false): Promise<User> {
this.log.log(LogType.Auth, null); this.log.log(LogType.Auth, null);
return this.userRepo.findById(id); let user = await this.userRepo.findById(id);
if (withImpersination) {
const impersination = await this.impersinationRepo.findOne({
where: { fromUser: { id: user.id }, endedAt: IsNull() },
relations: ['toUser']
});
if (impersination) {
return this.userRepo.findById(impersination.toUser.id)
}
}
return user;
} }
async getNewToken(refresh: string) { async getNewToken(refresh: string) {

View File

@@ -12,7 +12,7 @@ export class CustomerService {
throw new HttpException({ message: 'Der Benutzer ist nicht verfügbar.', field: 'user' }, HttpStatus.UNPROCESSABLE_ENTITY); throw new HttpException({ message: 'Der Benutzer ist nicht verfügbar.', field: 'user' }, HttpStatus.UNPROCESSABLE_ENTITY);
} }
if (!data.name || data.name.length === 0) { if (!data.name || data.name.length === 0) {
throw new HttpException({ message: 'Der Name des Kunden ist erforderlich.', field: 'name' }, HttpStatus.UNPROCESSABLE_ENTITY); throw new HttpException({ message: 'Der Name des Mietern ist erforderlich.', field: 'name' }, HttpStatus.UNPROCESSABLE_ENTITY);
} }
if (!data.system) { if (!data.system) {
throw new HttpException({ message: 'Die Schließanlage ist nicht gefüllt.', field: 'system' }, HttpStatus.UNPROCESSABLE_ENTITY); throw new HttpException({ message: 'Die Schließanlage ist nicht gefüllt.', field: 'system' }, HttpStatus.UNPROCESSABLE_ENTITY);

View File

@@ -24,6 +24,11 @@ export class CylinderController {
return this.service.getCylinders(req.user); return this.service.getCylinders(req.user);
} }
@Get('archive')
getCylinderArchive(@Req() req: AuthenticatedRequest): Promise<Cylinder[]> {
return this.service.getDeletedCylinders(req.user);
}
@Delete(':id') @Delete(':id')
deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) { deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.deleteCylinder(req.user, id); return this.service.deleteCylinder(req.user, id);
@@ -44,4 +49,9 @@ export class CylinderController {
) { ) {
return this.service.createCylinder(req.user, b); return this.service.createCylinder(req.user, b);
} }
@Put(':id/restore')
restoreKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.restoreCylinder(req.user, id);
}
} }

View File

@@ -1,17 +1,19 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Cylinder, User } from 'src/model/entitites'; import { Cylinder, User } from 'src/model/entitites';
import { ActivityRepository, CylinderRepository, KeyRepository } from 'src/model/repositories'; import { CylinderRepository, KeyRepository } from 'src/model/repositories';
import { ActivityHelperService } from 'src/shared/service/activity.logger.service';
import { HelperService } from 'src/shared/service/system.helper.service'; import { HelperService } from 'src/shared/service/system.helper.service';
import { IsNull, Not } from 'typeorm';
@Injectable() @Injectable()
export class CylinderService { export class CylinderService {
constructor( constructor(
private readonly cylinderRepo: CylinderRepository, private readonly cylinderRepo: CylinderRepository,
private readonly keyRepo: KeyRepository, private readonly keyRepo: KeyRepository,
private systemActivityRepo: ActivityRepository,
private readonly helper: HelperService, private readonly helper: HelperService,
private readonly configService: ConfigService private readonly configService: ConfigService,
private activityService: ActivityHelperService
) {} ) {}
get isDevelopMode(): boolean { get isDevelopMode(): boolean {
@@ -39,7 +41,8 @@ export class CylinderService {
const keysToDelete = cylinder.keys.filter(k => k.cylinder.length == 1); const keysToDelete = cylinder.keys.filter(k => k.cylinder.length == 1);
await this.keyRepo.softRemove(keysToDelete); await this.keyRepo.softRemove(keysToDelete);
await this.cylinderRepo.softDelete({id: cylinder.id}) await this.cylinderRepo.softDelete({id: cylinder.id});
this.activityService.logCylinderDeleted(user, cylinder)
return; return;
} }
@@ -56,13 +59,36 @@ export class CylinderService {
} }
async createCylinder(user: User, cylinder: Partial<Cylinder>) { async createCylinder(user: User, cylinder: Partial<Cylinder>) {
const c = await this.cylinderRepo.save(this.cylinderRepo.create(cylinder)); try {
const c = await this.cylinderRepo.save(this.cylinderRepo.create(cylinder));
this.systemActivityRepo.save({ this.activityService.logCylinderCreated(user, c);
message: `Zylinder ${(c as any).name} angelegt`,
user: user,
system: (c as any).system
});
return c return c
} catch (e) {
// this.log.log()
throw new HttpException('Zylinder konnte nicht angelegt werden', HttpStatus.BAD_REQUEST)
}
}
getDeletedCylinders(user: User) {
return this.cylinderRepo.find({
where: {
system: { managers: { id: user.id } },
deletedAt: Not(IsNull()),
},
withDeleted: true,
order: { deletedAt: { direction: 'DESC' } },
});
}
async restoreCylinder(user: User, keyID: string) {
const cylinder = await this.cylinderRepo.findOneOrFail({
where: { system: { managers: { id: user.id } } , id: keyID },
withDeleted: true,
});
cylinder.deletedAt = null;
await this.activityService.logCylinderRestored(user, cylinder);
await this.helper.deleteKeyArchiveCache();
return this.cylinderRepo.save(cylinder);
} }
} }

View File

@@ -7,48 +7,57 @@ import {
Post, Post,
Put, Put,
Req, Req,
Sse,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { KeyService } from './key.service'; import { KeyService } from './key.service';
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface'; import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
import { AuthGuard } from 'src/core/guards/auth.guard'; import { AuthGuard } from 'src/core/guards/auth.guard';
import { Key } from 'src/model/entitites'; import { Key } from 'src/model/entitites';
import { interval, map, Observable } from 'rxjs';
@UseGuards(AuthGuard)
@Controller('key') @Controller('key')
export class KeyController { export class KeyController {
constructor(private service: KeyService) {} constructor(private service: KeyService) {}
@UseGuards(AuthGuard)
@Get() @Get()
getKeys(@Req() req: AuthenticatedRequest) { getKeys(@Req() req: AuthenticatedRequest) {
return this.service.getUsersKeys(req.user); return this.service.getUsersKeys(req.user);
} }
@UseGuards(AuthGuard)
@Get('lost') @Get('lost')
getLostKeys(@Req() req: AuthenticatedRequest) { getLostKeys(@Req() req: AuthenticatedRequest) {
return this.service.getLostKeys(req.user); return this.service.getLostKeys(req.user);
} }
@UseGuards(AuthGuard)
@Post() @Post()
postKey(@Req() req: AuthenticatedRequest, @Body() key: Key) { postKey(@Req() req: AuthenticatedRequest, @Body() key: Key) {
return this.service.createKey(req.user, key); return this.service.createKey(req.user, key);
} }
@UseGuards(AuthGuard)
@Put() @Put()
updateKey(@Req() req: AuthenticatedRequest, @Body() key: Key) { updateKey(@Req() req: AuthenticatedRequest, @Body() key: Key) {
return this.service.updateKey(req.user, key); return this.service.updateKey(req.user, key);
} }
@UseGuards(AuthGuard)
@Put(':id/restore') @Put(':id/restore')
restoreKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) { restoreKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.restoreKey(req.user, id); return this.service.restoreKey(req.user, id);
} }
@UseGuards(AuthGuard)
@Delete(':id') @Delete(':id')
deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) { deleteKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.deleteKey(req.user, id); return this.service.deleteKey(req.user, id);
} }
@UseGuards(AuthGuard)
@Post(':id/handover') @Post(':id/handover')
handoutKey( handoutKey(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@@ -58,12 +67,14 @@ export class KeyController {
return this.service.handoverKey(req.user, body, id); return this.service.handoverKey(req.user, body, id);
} }
@UseGuards(AuthGuard)
@Get(':id/handover') @Get(':id/handover')
getKeyHandouts(@Req() req: AuthenticatedRequest, @Param('id') id: string) { getKeyHandouts(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.getKeyHandovers(req.user, id); return this.service.getKeyHandovers(req.user, id);
} }
@Get('Archive') @UseGuards(AuthGuard)
@Get('archive')
getArchive(@Req() req: AuthenticatedRequest) { getArchive(@Req() req: AuthenticatedRequest) {
return this.service.getDeletedKeys(req.user); return this.service.getDeletedKeys(req.user);
} }

View File

@@ -5,10 +5,12 @@ import { DatabaseModule } from 'src/shared/database/database.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SharedServiceModule } from 'src/shared/service/shared.service.module'; import { SharedServiceModule } from 'src/shared/service/shared.service.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MailModule } from '../mail/mail.module';
import { SseModule } from '../realtime/sse/sse.module';
@Module({ @Module({
controllers: [KeyController], controllers: [KeyController],
providers: [KeyService, ConfigService], providers: [KeyService, ConfigService],
imports: [DatabaseModule, AuthModule, SharedServiceModule], imports: [DatabaseModule, AuthModule, SharedServiceModule, MailModule, SseModule],
}) })
export class KeyModule {} export class KeyModule {}

View File

@@ -11,6 +11,8 @@ import { HelperService } from 'src/shared/service/system.helper.service';
import { FindOperator, IsNull, Not } from 'typeorm'; import { FindOperator, IsNull, Not } from 'typeorm';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MailService } from '../mail/mail.service';
import { SseService } from '../realtime/sse/sse.service';
@Injectable() @Injectable()
export class KeyService { export class KeyService {
@@ -20,8 +22,12 @@ export class KeyService {
private readonly handoverRepo: KeyHandoutRepository, private readonly handoverRepo: KeyHandoutRepository,
private readonly activityService: ActivityHelperService, private readonly activityService: ActivityHelperService,
private readonly helper: HelperService, private readonly helper: HelperService,
private readonly configService: ConfigService private readonly configService: ConfigService,
) {} private readonly mailService: MailService,
private readonly sseService: SseService
) {
console.log("INIT KEYSERVICE")
}
get isDevelopMode(): boolean { get isDevelopMode(): boolean {
return (this.configService.get('DEVELOP_MODE') || '').toLowerCase() == 'true'; return (this.configService.get('DEVELOP_MODE') || '').toLowerCase() == 'true';
@@ -72,8 +78,32 @@ export class KeyService {
} }
if (k.keyLost != key.keyLost) { if (k.keyLost != key.keyLost) {
await this.activityService.logKeyLostUpdate(user, key, key.keyLost); await this.activityService.logKeyLostUpdate(user, key, key.keyLost);
try {
const k = await this.keyrepository.findOne({
where: { id: key.id },
relations: ['cylinder', 'cylinder.system', 'cylinder.system.managers', 'cylinder.system.managers.settings'],
withDeleted: false
});
for (const to of k.cylinder[0].system.managers.filter(m => m.settings.sendSystemUpdateMails)) {
this.mailService.sendKeyLostOrFoundMail({ key, to } )
}
} catch (e) {
console.error(e);
}
}
const saved = await this.keyrepository.save(this.keyrepository.create(key));
this.sendKeysToSSE(saved);
return saved;
}
private async sendKeysToSSE(key: Key) {
const system = await this.helper.getSystemOfKey(key)
for (let manager of system.managers) {
const keys = await this.getUsersKeys(manager);
this.sseService.sendKeysToUsers(manager.id, keys)
} }
return this.keyrepository.save(this.keyrepository.create(key));
} }
@@ -109,6 +139,20 @@ export class KeyService {
); );
this.activityService.logKeyHandover(user, key, key.cylinder[0].system, res); this.activityService.logKeyHandover(user, key, key.cylinder[0].system, res);
try {
if (key && key.cylinder && key.cylinder[0].system) {
const managerOb: Key = await this.keyrepository.findOne({
where: { id: keyID },
relations: [ 'cylinder', 'cylinder.system', 'cylinder.system.managers', 'cylinder.system.managers.settings' ]
});
managerOb.cylinder[0].system.managers.filter(m => m.settings.sendSystemUpdateMails).forEach(m => {
this.mailService.sendKeyHandoutMail({ to: m, key, handoutAction: res })
})
}
} catch (e){
console.log(e)
}
this.sendKeysToSSE(key);
return res; return res;
} }
@@ -144,18 +188,22 @@ export class KeyService {
} }
} }
async createKey(user: User, key: any) { async createKey(user: User, key: any): Promise<Key> {
const k = await this.keyrepository.save(this.keyrepository.create(key)); const k = await this.keyrepository.save(this.keyrepository.create(key)) as any as Key;
this.activityService.logKeyCreated(user, key, key.cylinder[0].system); this.activityService.logKeyCreated(user, key, key.cylinder[0].system);
this.sendKeysToSSE(k as any)
return k; return k;
} }
async deleteKey(user: User, id: string) { async deleteKey(user: User, id: string): Promise<Key> {
const key = await this.keyrepository.findOneOrFail({ const key = await this.keyrepository.findOneOrFail({
where: { id, cylinder: { system: { managers: { id: user.id } } } }, where: { id, cylinder: { system: { managers: { id: user.id } } } },
}); });
await this.activityService.logDeleteKey(user, key); await this.activityService.logDeleteKey(user, key);
return this.keyrepository.softRemove(key); const k = await this.keyrepository.softRemove(key);
this.sendKeysToSSE(k)
return k;
} }
getDeletedKeys(user: User) { getDeletedKeys(user: User) {
@@ -169,7 +217,7 @@ export class KeyService {
}); });
} }
async restoreKey(user: User, keyID: string) { async restoreKey(user: User, keyID: string): Promise<Key> {
const key = await this.keyrepository.findOneOrFail({ const key = await this.keyrepository.findOneOrFail({
where: { cylinder: { system: { managers: { id: user.id } } }, id: keyID }, where: { cylinder: { system: { managers: { id: user.id } } }, id: keyID },
@@ -178,6 +226,8 @@ export class KeyService {
key.deletedAt = null; key.deletedAt = null;
await this.activityService.logKeyRestored(user, key); await this.activityService.logKeyRestored(user, key);
await this.helper.deleteKeyArchiveCache(); await this.helper.deleteKeyArchiveCache();
return this.keyrepository.save(key); const k = await this.keyrepository.save(key);
this.sendKeysToSSE(k)
return k;
} }
} }

View File

@@ -0,0 +1,7 @@
import { LogType } from "./log.service";
export class LogMockService {
log = jest.fn().mockImplementation((type: LogType, data: any) => {
return true;
})
}

View File

@@ -23,7 +23,7 @@ export class LogService {
} }
private async logAuthEvent(data: User) { private async logAuthEvent(data: User) {
console.error("auth logging not implemented") // console.error("auth logging not implemented")
} }
} }
@@ -34,7 +34,9 @@ export enum LogType {
export enum EmailEvent { export enum EmailEvent {
GrantSystemAccess, GrantSystemAccess,
RemoveSystemAccess RemoveSystemAccess,
KeyHandout,
KeyLostOrFound
} }
export interface EmailLogDto { export interface EmailLogDto {

View File

@@ -3,17 +3,99 @@ import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { EmailEvent, LogService, LogType } from "../log/log.service"; import { EmailEvent, LogService, LogType } from "../log/log.service";
import { KeySystem } from "src/model/entitites/system.entity"; import { KeySystem } from "src/model/entitites/system.entity";
import { User } from "src/model/entitites"; import { Key, KeyHandout, User } from "src/model/entitites";
@Injectable() @Injectable()
export class MailService { export class MailService {
constructor( constructor(
private mailserService: MailerService, private mailserService: MailerService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly logService: LogService, private readonly logService: LogService
) { ) {
} }
async sendKeyLostOrFoundMail({to, key}: {to: User, key: Key}) {
// const subject
const keyAction = key.keyLost == null ? 'wurde gefunden' : 'wurde als verloren gemeldet';
const keyExtendedAction = key.keyLost == null ? `wurde als gefunden gemeldet` : `wurde am ${new Date(key.keyLost).toLocaleDateString()} als verloren gemeldet`;
const subject = key.keyLost == null ? 'Schlüssel gefunden' : 'Schlüssel verloren';
const context = {
keyAction,
keyExtendedAction,
firstName: to.firstName,
keyNr: key.nr,
keyName: key.name,
url: 'https://keyvaultpro.de/keys?nr=' + key.nr
}
this.mailserService.sendMail({
template: './key-handout-changed',
to: to.username,
from: this.configService.get<string>('MAILER_FROM'),
subject: subject,
context
}).then(v => {
this.logService.log(LogType.Mail, {
to: to.username,
success: true,
message: v.response,
type: EmailEvent.KeyLostOrFound,
system: key.cylinder[0].system,
context: JSON.stringify(key)
})
}).catch(e => {
this.logService.log(LogType.Mail, {
to,
success: false,
message: e.response,
type: EmailEvent.KeyLostOrFound,
system: key.cylinder[0].system,
context: JSON.stringify(key)
})
})
}
async sendKeyHandoutMail({to, key, handoutAction}: {to: User, key: Key, handoutAction: KeyHandout}) {
const keyAction = handoutAction.direction == 'out' ? 'wurde ausgegeben' : 'wurde zurückgegeben';
const keyExtendedAction = handoutAction.direction == 'return' ? `wurde von ${handoutAction.customer.name} zurückgegeben` : `wurde an ${handoutAction.customer.name} ausgegeben`;
const subject = handoutAction.direction == 'out' ? 'Schlüssel ausgegeben' : 'Schlüssel zurückgegeben';
const context = {
keyAction,
keyExtendedAction,
firstName: to.firstName,
keyNr: key.nr,
keyName: key.name,
url: 'https://keyvaultpro.de/keys?nr=' + key.nr
}
this.mailserService.sendMail({
template: './key-handout-changed',
to: to.username,
from: this.configService.get<string>('MAILER_FROM'),
subject: subject,
context
}).then(v => {
this.logService.log(LogType.Mail, {
to: to.username,
success: true,
message: v.response,
type: EmailEvent.KeyHandout,
system: key.cylinder[0].system,
context: JSON.stringify(handoutAction)
})
}).catch(e => {
this.logService.log(LogType.Mail, {
to,
success: false,
message: e.response,
type: EmailEvent.KeyHandout,
system: key.cylinder[0].system,
context: JSON.stringify(handoutAction)
})
})
}
async sendAccessGrantedMail({to, system}: {to: User, system: KeySystem}) { async sendAccessGrantedMail({to, system}: {to: User, system: KeySystem}) {
this.mailserService.sendMail({ this.mailserService.sendMail({
template: './access', template: './access',

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class SseTicketService {
private userTickets: Map<string, {userId: string, used: boolean}> = new Map();
generateTicket(userId: string): {ticket: string } {
const ticket = crypto.randomUUID();
this.userTickets.set(ticket, { userId, used: false });
return {ticket};
}
getUserIdToTicket(ticketId: string): string {
if (!this.userTickets.has(ticketId)) { return null; }
const ticket = this.userTickets.get(ticketId);
if (!ticket || ticket.used) { return null; }
return ticket.userId;
}
}

View File

@@ -0,0 +1,65 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SseController } from './sse.controller';
import { SseTicketService } from './sse-ticket.service';
import { UserService } from 'src/modules/user/user.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from 'src/modules/auth/auth.service';
import { SseService } from './sse.service';
import { Subject } from 'rxjs';
describe('SseController', () => {
let controller: SseController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SseController],
imports: [ConfigModule],
providers: [
ConfigService,
{ provide: JwtService, useClass: MockJwTService },
{ provide: SseTicketService, useClass: MockSseTicketService },
{ provide: AuthService, useClass: MockAuthService },
{ provide: SseService, useClass: MockSSEService }
]
}).compile();
controller = module.get<SseController>(SseController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should generate a Ticket', () => {
const t = controller.getTicket({ user: { id: 123}} as any);
expect(controller['ticketService'].generateTicket).toHaveBeenCalled()
});
it('should generate a SSE stream', async () => {
await controller.sse('abc');
expect(controller["sseService"].register).toHaveBeenCalled();
})
});
class MockSseTicketService {
generateTicket = jest.fn().mockImplementation( (id) => {
return {ticket: 'test-ticket-id'}
});
getUserIdToTicket = jest.fn().mockImplementation( (ticket) => {
return 99;
})
}
class MockSSEService {
register = jest.fn().mockImplementation( (id) => {
return new Subject()
})
}
class MockJwTService {}
class MockAuthService {}

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Param, Query, Req, Sse, UnauthorizedException, UseGuards } from '@nestjs/common';
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
import { SseTicketService } from './sse-ticket.service';
import { AuthGuard } from 'src/core/guards/auth.guard';
import { finalize, Observable } from 'rxjs';
import { SseService } from './sse.service';
@Controller('sse')
export class SseController {
constructor(private ticketService: SseTicketService, private sseService: SseService) {}
@UseGuards(AuthGuard)
@Get('ticket')
getTicket(@Req() req: AuthenticatedRequest) {
return this.ticketService.generateTicket(req.user.id)
}
@Sse('key')
async sse(@Query('ticket') ticket: string): Promise<Observable<any>> {
const userId = this.ticketService.getUserIdToTicket(ticket);
if (!userId) throw new UnauthorizedException('Invalid/expired ticket');
if (!userId) throw new UnauthorizedException('Invalid/expired ticket');
return this.sseService.register(userId).pipe(
finalize(() => {
this.sseService.unregister(userId)
})
);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { SseController } from './sse.controller';
import { DatabaseModule } from 'src/shared/database/database.module';
import { SseTicketService } from './sse-ticket.service';
import { AuthModule } from 'src/modules/auth/auth.module';
import { SharedServiceModule } from 'src/shared/service/shared.service.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { SseService } from './sse.service';
@Module({
controllers: [SseController],
imports: [DatabaseModule, AuthModule, SharedServiceModule, MailModule],
providers: [SseTicketService, SseService],
exports: [SseService]
})
export class SseModule {}

View File

@@ -0,0 +1,42 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SseService } from './sse.service';
import { Key } from 'src/model/entitites';
describe('SseService', () => {
let service: SseService;
const userId = 'testuserid-54';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SseService],
}).compile();
service = module.get<SseService>(SseService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should register new SSE clients', () => {
const res = service.register(userId);
expect(service["clients"].has(userId)).toBeTruthy();
});
it('should send keys to User', () => {
const sub = service.register(userId);
const key = new Key();
const keyId = 'testkey-123';
key.id = keyId;
sub.subscribe({
next: val => {
expect(val.data).toBeTruthy();
expect(val.data[0]?.id).toBe(keyId)
}
})
service.sendKeysToUsers(userId, [key]);
})
});

View File

@@ -0,0 +1,30 @@
import { Injectable,MessageEvent } from '@nestjs/common';
import { Subject } from 'rxjs';
import { Key } from 'src/model/entitites';
@Injectable()
export class SseService {
private clients = new Map<string, Subject<MessageEvent>>();
sendKeysToUsers(userId: string, keys: Key[]) {
try {
const sub = this.clients.get(userId);
if (!sub) { return; }
sub.next({ data: keys })
} catch {}
}
register(userId: string) {
const subj = new Subject<MessageEvent>();
this.clients.set(userId, subj);
return subj;
}
unregister(userId: string) {
if (!this.clients.has(userId)) { return; }
const sub = this.clients.get(userId);
sub.unsubscribe();
this.clients.delete(userId);
}
}

View File

@@ -1,4 +1,6 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreateSystemDto } from './create-system.dto'; import { CreateSystemDto } from './create-system.dto';
export class UpdateSystemDto extends PartialType(CreateSystemDto) {} export class UpdateSystemDto extends PartialType(CreateSystemDto) {
id: string;
}

View File

@@ -8,6 +8,7 @@ import {
Delete, Delete,
Req, Req,
UseGuards, UseGuards,
Put,
} from '@nestjs/common'; } from '@nestjs/common';
import { SystemService } from './system.service'; import { SystemService } from './system.service';
import { CreateSystemDto } from './dto/create-system.dto'; import { CreateSystemDto } from './dto/create-system.dto';
@@ -32,6 +33,11 @@ export class SystemController {
findAll(@Req() req: AuthenticatedRequest) { findAll(@Req() req: AuthenticatedRequest) {
return this.systemService.findAll(req.user); return this.systemService.findAll(req.user);
} }
@Get('archive')
findDeleted(@Req() req: AuthenticatedRequest) {
return this.systemService.findDeleted(req.user);
}
@Get(':id/manager') @Get(':id/manager')
getManagers(@Param('id') id: string) { getManagers(@Param('id') id: string) {
@@ -47,13 +53,18 @@ export class SystemController {
return this.systemService.findOne(id); return this.systemService.findOne(id);
} }
@Patch(':id') @Put()
update(@Param('id') id: string, @Body() updateSystemDto: UpdateSystemDto) { update(@Req() req: AuthenticatedRequest, @Body() updateSystemDto: UpdateSystemDto) {
return this.systemService.update(id, updateSystemDto); return this.systemService.update(req.user, updateSystemDto);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.systemService.remove(id); return this.systemService.remove(id);
} }
@Put(':id/restore')
restoreKey(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.systemService.restore(req.user, id);
}
} }

View File

@@ -5,10 +5,11 @@ import { AuthModule } from '../auth/auth.module';
import { DatabaseModule } from 'src/shared/database/database.module'; import { DatabaseModule } from 'src/shared/database/database.module';
import { MailModule } from '../mail/mail.module'; import { MailModule } from '../mail/mail.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { SharedServiceModule } from 'src/shared/service/shared.service.module';
@Module({ @Module({
controllers: [SystemController], controllers: [SystemController],
providers: [SystemService, ConfigService], providers: [SystemService, ConfigService],
imports: [AuthModule, DatabaseModule, MailModule], imports: [AuthModule, DatabaseModule, MailModule, SharedServiceModule],
}) })
export class SystemModule {} export class SystemModule {}

View File

@@ -0,0 +1,65 @@
import { TestingModule, Test } from "@nestjs/testing"
import { SystemService } from "./system.service";
import { ActivityHelperService } from "src/shared/service/activity.logger.service";
import { ActivityHelperMockService } from "src/shared/service/activity.logger.service.mock";
import { MockUserRepository, MockKeySystemRepository, MockMailService } from "../../../mocks";
import { KeySystemRepository, UserRepository } from "src/model/repositories";
import { MailService } from "../mail/mail.service";
import { ConfigService } from "@nestjs/config";
import { KeySystem } from "src/model/entitites/system.entity";
import { User } from "src/model/entitites";
describe('KeySystemServce', () => {
let service: SystemService;
let user: User;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SystemService,
{ provide: KeySystemRepository, useClass: MockKeySystemRepository },
{ provide: MailService, useClass: MockMailService },
{ provide: UserRepository, useClass: MockUserRepository },
{ provide: ActivityHelperService, useClass: ActivityHelperMockService },
ConfigService
],
imports: []
}).compile();
service = module.get<SystemService>(SystemService);
user = await service['userRepo'].findByUsername('mockuser@test.de', { settings: false })
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should create a system', async () => {
const name = 'TestSystem0123'
const s = await service.create(user, { name });
expect(s).not.toBeNull();
expect(s.id).not.toBeNull();
expect(s.createdAt).not.toBeNull();
expect(s.name).toBe(name);
expect(s.managers).toContain(user);
expect(service['systemRepo'].create).toHaveBeenCalled()
expect(service['systemRepo'].save).toHaveBeenCalled()
})
it('should delete systems', async () => {
const repo = service['systemRepo'];
const system = await service.remove('abc');
expect(repo.softRemove).toHaveBeenCalled();
expect(system.deletedAt).not.toBeNull();
})
it('should restore systems', async () => {
const repo = service['systemRepo'];
const system = await service.restore(user, 'abc');
expect(repo.save).toHaveBeenCalled();
expect(system.deletedAt).toBeNull();
})
})

View File

@@ -1,20 +1,21 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateSystemDto } from './dto/create-system.dto'; import { CreateSystemDto } from './dto/create-system.dto';
import { UpdateSystemDto } from './dto/update-system.dto'; import { UpdateSystemDto } from './dto/update-system.dto';
import { ActivityRepository, KeySystemRepository, UserRepository } from 'src/model/repositories'; import { KeySystemRepository, UserRepository } from 'src/model/repositories';
import { User } from 'src/model/entitites'; import { User } from 'src/model/entitites';
import { IUser } from 'src/model/interface';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ActivityHelperService } from 'src/shared/service/activity.logger.service';
import { IsNull, Not } from 'typeorm';
@Injectable() @Injectable()
export class SystemService { export class SystemService {
constructor( constructor(
private systemRepo: KeySystemRepository, private systemRepo: KeySystemRepository,
private userRepo: UserRepository, private userRepo: UserRepository,
private systemActivityRepo: ActivityRepository,
private mailService: MailService, private mailService: MailService,
private readonly configService: ConfigService private readonly configService: ConfigService,
private readonly activityService: ActivityHelperService
) {} ) {}
get isDevelopMode(): boolean { get isDevelopMode(): boolean {
@@ -25,15 +26,7 @@ export class SystemService {
const sys = this.systemRepo.create(createSystemDto); const sys = this.systemRepo.create(createSystemDto);
sys.managers = [user]; sys.managers = [user];
try { try {
const res = await this.systemRepo.save(sys); const res = await this.systemRepo.save(sys);
this.systemActivityRepo.save({
message: `Schließanlage ${(res as any).name} angelegt`,
user: user,
system: res
});
return res; return res;
} catch (e) { } catch (e) {
throw new HttpException(e.code, HttpStatus.UNPROCESSABLE_ENTITY); throw new HttpException(e.code, HttpStatus.UNPROCESSABLE_ENTITY);
@@ -44,6 +37,21 @@ export class SystemService {
let systems = await this.systemRepo.find({ let systems = await this.systemRepo.find({
where: { managers: { id: user.id } }, where: { managers: { id: user.id } },
order: { name: { direction: 'ASC' } }, order: { name: { direction: 'ASC' } },
relations: ['cylinders']
});
if (this.isDevelopMode) {
systems = systems.filter(s => s.name.toLocaleLowerCase().includes('develop'));
}
return systems;
}
async findDeleted(user: User) {
let systems = await this.systemRepo.find({
where: { managers: { id: user.id }, deletedAt: Not(IsNull()) },
order: { name: { direction: 'ASC' } },
withDeleted: true,
}); });
if (this.isDevelopMode) { if (this.isDevelopMode) {
@@ -57,13 +65,20 @@ export class SystemService {
return this.systemRepo.findOne({ where: { id: id } }); return this.systemRepo.findOne({ where: { id: id } });
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars async update(user: User, updateSystemDto: UpdateSystemDto) {
update(id: string, updateSystemDto: UpdateSystemDto) { if (!user || !user.id || !updateSystemDto.id) { throw new HttpException('forbidden', HttpStatus.FORBIDDEN); }
throw new HttpException( const system = await this.systemRepo.findOne({ where: { id: updateSystemDto.id, managers: { id: user.id } }, withDeleted: true });
`This action updates a #${id} system but is not implemented`,
HttpStatus.NOT_IMPLEMENTED, if (!system) { throw new HttpException('forbidden', HttpStatus.FORBIDDEN); }
);
return `This action updates a #${id} system`;
if (system.name !== updateSystemDto.name) {
await this.activityService.logSystemRenamed({ system, newName: updateSystemDto.name, user })
system.name = updateSystemDto.name;
}
return true;
} }
async remove(id: string) { async remove(id: string) {
@@ -124,4 +139,15 @@ export class SystemService {
return sys.managers; return sys.managers;
} }
async restore(user: User, id: string) {
const key = await this.systemRepo.findOneOrFail({
where: { id: id, managers: { id: user.id } },
withDeleted: true,
});
key.deletedAt = null;
// await this.activityService.logKeyRestored(user, key);
// await this.helper.deleteKeyArchiveCache();
return this.systemRepo.save(key);
}
} }

View File

@@ -4,8 +4,6 @@ import { UserService } from './user.service';
import { User } from 'src/model/entitites'; import { User } from 'src/model/entitites';
import { IUser } from 'src/model/interface'; import { IUser } from 'src/model/interface';
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface'; import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
import { HttpErrorByCode } from '@nestjs/common/utils/http-error-by-code.util';
import { HttpStatusCode } from 'axios';
import { UserSettings } from 'src/model/entitites/user/user.settings.entity'; import { UserSettings } from 'src/model/entitites/user/user.settings.entity';
@UseGuards(AuthGuard) @UseGuards(AuthGuard)

View File

@@ -13,8 +13,8 @@ export class UserService {
private readonly systemActivityRepo: ActivityRepository, private readonly systemActivityRepo: ActivityRepository,
private readonly userSettingsRepository: UserSettingsRepository, private readonly userSettingsRepository: UserSettingsRepository,
private readonly helper: HelperService, private readonly helper: HelperService,
) { ) {}
}
getAllUsers(): Promise<User[]> { getAllUsers(): Promise<User[]> {
@@ -46,7 +46,7 @@ export class UserService {
const keys = cylinders.map(c => c.keys).flat().map(k => k.id); const keys = cylinders.map(c => c.keys).flat().map(k => k.id);
const keycount = [...new Set(keys)] const keycount = [...new Set(keys)]
const handedOut = (await this.helper.getUsersKeys(user)).filter(k => k.handedOut).length; const handedOut = (await this.helper.getUsersKeys(user)).filter(k => k.handedOut && k.keyLost == null).length;
return { return {
keys: keycount.length, keys: keycount.length,
cylinders: cylinders.length, cylinders: cylinders.length,
@@ -67,4 +67,8 @@ export class UserService {
updateSettings(settings: UserSettings) { updateSettings(settings: UserSettings) {
return this.userSettingsRepository.save(settings); return this.userSettingsRepository.save(settings);
} }
getUserById(id: string) {
return this.userRepo.findOneBy({ id })
}
} }

View File

@@ -12,6 +12,7 @@ import {
} from 'src/model/entitites'; } from 'src/model/entitites';
import { EmailLog } from 'src/model/entitites/log'; import { EmailLog } from 'src/model/entitites/log';
import { KeySystem } from 'src/model/entitites/system.entity'; import { KeySystem } from 'src/model/entitites/system.entity';
import { Impersonation } from 'src/model/entitites/impersination.entity';
import { UserSettings } from 'src/model/entitites/user/user.settings.entity'; import { UserSettings } from 'src/model/entitites/user/user.settings.entity';
import { import {
ActivityRepository, ActivityRepository,
@@ -26,6 +27,7 @@ import {
} from 'src/model/repositories'; } from 'src/model/repositories';
import { KeyHandoutRepository } from 'src/model/repositories/key-handout.repository'; import { KeyHandoutRepository } from 'src/model/repositories/key-handout.repository';
import { EmailLogRepository } from 'src/model/repositories/log'; import { EmailLogRepository } from 'src/model/repositories/log';
import { ImpersonationRepository } from 'src/model/repositories/impersination.repository';
const ENTITIES = [ const ENTITIES = [
User, User,
@@ -39,6 +41,7 @@ const ENTITIES = [
Activity, Activity,
EmailLog, EmailLog,
UserSettings, UserSettings,
Impersonation
]; ];
const REPOSITORIES = [ const REPOSITORIES = [
UserRepository, UserRepository,
@@ -51,7 +54,8 @@ const REPOSITORIES = [
KeyHandoutRepository, KeyHandoutRepository,
ActivityRepository, ActivityRepository,
EmailLogRepository, EmailLogRepository,
UserSettingsRepository UserSettingsRepository,
ImpersonationRepository
]; ];
@Module({ @Module({

View File

@@ -0,0 +1,3 @@
export class ActivityHelperMockService {
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { Key, KeyHandout, User } from "src/model/entitites"; import { Cylinder, Key, KeyHandout, User } from "src/model/entitites";
import { KeySystem } from "src/model/entitites/system.entity"; import { KeySystem } from "src/model/entitites/system.entity";
import { ActivityRepository, CylinderRepository, KeyRepository } from "src/model/repositories"; import { ActivityRepository, CylinderRepository, KeyRepository } from "src/model/repositories";
import { HelperService } from "./system.helper.service"; import { HelperService } from "./system.helper.service";
@@ -14,6 +14,17 @@ export class ActivityHelperService {
private readonly cylinderRepo: CylinderRepository, private readonly cylinderRepo: CylinderRepository,
) {} ) {}
async logSystemRenamed({system, newName, user}: { system: KeySystem, newName: string, user: User}) {
let msg = `Schließanlage von ${system.name} zu ${newName} umbenannt`;
return this.activityRepo.save(
this.activityRepo.create({
system,
user,
message: msg,
}))
}
async logDeleteKey(user: User, key: Key, system?: KeySystem) { async logDeleteKey(user: User, key: Key, system?: KeySystem) {
if (!key || !user) { return; } if (!key || !user) { return; }
@@ -115,4 +126,41 @@ export class ActivityHelperService {
message: msg, message: msg,
})) }))
} }
async logCylinderRestored(user: User, cylinder: Cylinder) {
let msg = `Zylinder ${cylinder.name} wiederhergestellt`;
const system: KeySystem = await this.helper.getSystemOfCylinder(cylinder);
this.activityRepo.save(
this.activityRepo.create({
system,
user,
message: msg,
}))
}
async logCylinderCreated(user: User, cylinder: Cylinder) {
const msg = `Zylinder ${cylinder.name} angelegt`;
const system: KeySystem = await this.helper.getSystemOfCylinder(cylinder);
this.activityRepo.save(
this.activityRepo.create({
system,
user,
message: msg,
}))
}
async logCylinderDeleted(user: User, cylinder: Cylinder) {
let msg = `Zylinder ${cylinder.name} gelöscht`;
const system: KeySystem = await this.helper.getSystemOfCylinder(cylinder);
this.activityRepo.save(
this.activityRepo.create({
system,
user,
message: msg,
}))
}
} }

View File

@@ -11,7 +11,7 @@ export class HelperService {
private readonly systemRepository: KeySystemRepository, private readonly systemRepository: KeySystemRepository,
private readonly cylinderRepository: CylinderRepository, private readonly cylinderRepository: CylinderRepository,
private readonly keyRepo: KeyRepository, private readonly keyRepo: KeyRepository,
private cacheManager: Cache // private cacheManager: Cache
) {} ) {}
@@ -39,10 +39,15 @@ export class HelperService {
return keys; return keys;
} }
/**
* Sucht das System eines Schlüssels und gibt es als Promise zurück
* @param key key
* @returns system: KeySystem
*/
async getSystemOfKey(key: Key): Promise<KeySystem> { async getSystemOfKey(key: Key): Promise<KeySystem> {
const k = await this.keyRepo.findOne({ const k = await this.keyRepo.findOne({
where: { id: key.id }, where: { id: key.id },
relations: ['cylinder', 'cylinder.system'], relations: ['cylinder', 'cylinder.system', 'cylinder.system.managers'],
withDeleted: true, withDeleted: true,
}); });
this.cache() this.cache()
@@ -50,12 +55,27 @@ export class HelperService {
return found.system; return found.system;
} }
/**
* Gibt das System eines Zylinders und gibt es als Promise zurück
* @param cylinder Zylinder
* @returns Promise<KeySystem>
*/
async getSystemOfCylinder(cylinder: Cylinder): Promise<KeySystem> {
const k = await this.cylinderRepository.findOne({
where: { id: cylinder.id },
relations: ['system'],
withDeleted: true,
});
this.cache()
return k.system;
}
async cache() { async cache() {
const value = await this.cacheManager.store.keys() // const value = await this.cacheManager.store.keys()
console.log(value) // console.log(value)
} }
async deleteKeyArchiveCache() { async deleteKeyArchiveCache() {
await this.cacheManager.del('/key/archive'); // await this.cacheManager.del('/key/archive');
} }
} }

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zugriff gewährt</title>
<style>
/* General styles */
body {
font-family: 'Roboto', Arial, sans-serif;
background-color: #f5f5f5;
color: #424242;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background: #ffffff;
border-radius: 12px;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background-color: #2196f3; /* Freundliches Blau */
color: #ffffff;
padding: 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 1.5rem;
}
.content {
padding: 24px 20px;
line-height: 1.6;
color: #424242;
}
.content p {
margin: 0 0 16px;
}
.button-container {
text-align: center;
margin: 24px 0;
}
.btn {
display: inline-block;
padding: 12px 24px;
font-size: 16px;
color: #ffffff;
background-color: #2196f3; /* Gleicher Blauton */
text-decoration: none;
border-radius: 24px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.btn:hover {
background-color: #1769aa; /* Dunkleres Blau für Hover */
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3);
}
.footer {
background-color: #f5f5f5;
text-align: center;
padding: 16px;
font-size: 0.875rem;
color: #757575;
}
.footer a {
color: #2196f3;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Schlüssel {{ keyAction }}</h1>
</div>
<div class="content">
<p>Hallo {{firstName}},</p>
<p>der Schlüssel {{ keyName }} ({{ keyNr }}) wurde {{ keyExtendedAction }}</p>
<div class="button-container">
<a href="{{ url }}" class="btn">Website aufrufen</a>
</div>
</div>
<div class="footer">
</div>
</div>
</body>
</html>

View File

@@ -1,5 +1,5 @@
# Verwende das offizielle Node.js 14 Image als Basis # Verwende das offizielle Node.js 14 Image als Basis
FROM node:18 AS builder FROM node:22 AS builder
# Setze das Arbeitsverzeichnis im Container # Setze das Arbeitsverzeichnis im Container
WORKDIR /app WORKDIR /app

View File

@@ -34,7 +34,6 @@
"styles": [ "styles": [
"@angular/material/prebuilt-themes/azure-blue.css", "@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.scss", "src/styles.scss",
"src/styles/ag.css",
"node_modules/@ngxpert/hot-toast/src/styles/styles.css" "node_modules/@ngxpert/hot-toast/src/styles/styles.css"
], ],
"scripts": [] "scripts": []
@@ -59,7 +58,8 @@
"replace": "src/environments/environment.ts", "replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts" "with": "src/environments/environment.prod.ts"
} }
] ],
"serviceWorker": "ngsw-config.json"
}, },
"development": { "development": {
"optimization": false, "optimization": false,
@@ -89,7 +89,11 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"test": { "test": {
"builder": "@angular/build:unit-test" "builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"setupFiles": ["src/test-setup.ts"]
}
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build:extract-i18n" "builder": "@angular/build:extract-i18n"
@@ -122,5 +126,8 @@
"@schematics/angular:resolver": { "@schematics/angular:resolver": {
"typeSeparator": "." "typeSeparator": "."
} }
},
"cli": {
"analytics": false
} }
} }

30
client/ngsw-config.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

1466
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,10 @@
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"start:remote": "ng serve --configuration remote",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "jest", "test": "ng test",
"test:recent": "jest --onlyChanged", "test:recent": "jest --onlyChanged",
"test:watch": "jest --watch --onlyChanged" "test:watch": "jest --watch --onlyChanged"
}, },
@@ -24,6 +25,7 @@
"@angular/platform-browser": "^21.1.4", "@angular/platform-browser": "^21.1.4",
"@angular/platform-browser-dynamic": "^21.1.4", "@angular/platform-browser-dynamic": "^21.1.4",
"@angular/router": "^21.1.4", "@angular/router": "^21.1.4",
"@angular/service-worker": "^21.1.4",
"@ngneat/overview": "^7.0.0", "@ngneat/overview": "^7.0.0",
"@ngxpert/hot-toast": "^6.1.0", "@ngxpert/hot-toast": "^6.1.0",
"ag-grid-angular": "^35.1.0", "ag-grid-angular": "^35.1.0",
@@ -33,14 +35,14 @@
"zone.js": "~0.15.1" "zone.js": "~0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@analogjs/vitest-angular": "^2.2.3",
"@angular/build": "^21.1.4", "@angular/build": "^21.1.4",
"@angular/cli": "^21.1.4", "@angular/cli": "^21.1.4",
"@angular/compiler-cli": "^21.1.4", "@angular/compiler-cli": "^21.1.4",
"@faker-js/faker": "^9.0.3", "@faker-js/faker": "^9.0.3",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^28.0.0", "jsdom": "^28.1.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.16",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",

View File

@@ -1,11 +1,8 @@
{ {
"/api": { "/api": {
"target": "http://keyvaultpro.de:3701", "target": "https://keyvaultpro.de",
"secure": false, "secure": true,
"logLevel": "debug", "changeOrigin": true,
"changeOrigin": true, "logLevel": "debug"
"pathRewrite": { }
"^/api": ""
}
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Keyvault Pro",
"short_name": "KVP",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

View File

@@ -1,3 +0,0 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();

View File

@@ -1,4 +1,4 @@
import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHotToastConfig } from '@ngxpert/hot-toast'; import { provideHotToastConfig } from '@ngxpert/hot-toast';
@@ -6,6 +6,8 @@ import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { tokenInterceptor } from './core/interceptor/token.interceptor'; import { tokenInterceptor } from './core/interceptor/token.interceptor';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideServiceWorker } from '@angular/service-worker';
import { OVERLAY_DEFAULT_CONFIG } from "@angular/cdk/overlay";
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withInterceptors([tokenInterceptor])) providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withInterceptors([tokenInterceptor]))
@@ -16,8 +18,18 @@ export const appConfig: ApplicationConfig = {
theme: 'toast', theme: 'toast',
autoClose: true, autoClose: true,
dismissible: false, dismissible: false,
duration: 5000 duration: 5000,
}), }),
provideAnimationsAsync() provideAnimationsAsync(), provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
}),
{
provide: OVERLAY_DEFAULT_CONFIG,
useValue: {
usePopover: false,
},
},
] ]
}; };

View File

@@ -1,6 +1,5 @@
import { inject, Injectable } from "@angular/core"; import { inject, Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, Router } from "@angular/router"; import { ActivatedRouteSnapshot, Router } from "@angular/router";
import { HotToastService } from "@ngxpert/hot-toast";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
@Injectable({ @Injectable({
@@ -9,7 +8,6 @@ import { AuthService } from "./auth.service";
export class AuthenticatedGuard { export class AuthenticatedGuard {
public isLoading = false; public isLoading = false;
private router = inject(Router); private router = inject(Router);
private toast = inject(HotToastService);
private authService = inject(AuthService); private authService = inject(AuthService);
async canActivate(route: ActivatedRouteSnapshot): async canActivate(route: ActivatedRouteSnapshot):

View File

@@ -5,6 +5,7 @@ import { BehaviorSubject, Observable, tap, of, catchError } from 'rxjs';
import { IUser } from '../../model/interface/user.interface'; import { IUser } from '../../model/interface/user.interface';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { HotToastService } from '@ngxpert/hot-toast'; import { HotToastService } from '@ngxpert/hot-toast';
import { ApiService } from '../../shared/api.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -16,6 +17,7 @@ export class AuthService {
private http: HttpClient = inject(HttpClient); private http: HttpClient = inject(HttpClient);
private router: Router = inject(Router); private router: Router = inject(Router);
private toast: HotToastService = inject(HotToastService); private toast: HotToastService = inject(HotToastService);
private api: ApiService = inject(ApiService);
private _user: IUser | null = null; private _user: IUser | null = null;
@@ -35,21 +37,27 @@ export class AuthService {
return this.user != null && this.user.role == 'admin'; return this.user != null && this.user.role == 'admin';
} }
getMe() { async getMe() {
if (!this.getAccessToken()) { if (!this.getAccessToken()) {
return false; return false;
} }
return new Promise(resolve => { const user = await this.api.getMe();
this.http.get<IUser>('/api/auth/me').subscribe({ if (user) {
next: user => { this._user = user;
this._user = user; return Promise.resolve(true);
resolve(true) }
}, return Promise.resolve(false)
error: () => { // return new Promise(resolve => {
resolve(false) // this.http.get<IUser>('/api/auth/me').subscribe({
} // next: user => {
}) // this._user = user;
}) // resolve(true)
// },
// error: () => {
// resolve(false)
// }
// })
// })
} }

View File

@@ -12,14 +12,14 @@
<mat-drawer-container class="example-container" autosize> <mat-drawer-container class="example-container" autosize>
<mat-drawer #drawer class="main_sidenav" mode="side" opened="true" style="border-right: 1px solid #dfdfdf"> <mat-drawer #drawer class="main_sidenav" mode="side" opened="true" style="border-right: 1px solid #dfdfdf">
<button matButton routerLink="/" routerLinkActive="mat-elevation-z1" [routerLinkActiveOptions]="{exact: true}">Home</button> <div class="nav-button" routerLink="/" routerLinkActive="active-link" [routerLinkActiveOptions]="{exact: true}"><mat-icon>home</mat-icon>Home</div>
<button matButton routerLink="/keys" routerLinkActive="mat-elevation-z1">Schlüssel</button> <div class="nav-button" routerLink="/keys" routerLinkActive="active-link"><mat-icon>key</mat-icon>Schlüssel</div>
<button matButton routerLink="/cylinders" routerLinkActive="mat-elevation-z1">Zylinder</button> <div class="nav-button" routerLink="/cylinders" routerLinkActive="active-link"><mat-icon>lock</mat-icon>Zylinder</div>
<button matButton routerLink="/systems" routerLinkActive="mat-elevation-z1">Schließanlagen</button> <div class="nav-button" routerLink="/systems" routerLinkActive="active-link"><mat-icon>admin_panel_settings</mat-icon>Schließanlagen</div>
@if (isAdmin) { @if (isAdmin) {
<button matButton routerLink="/users" routerLinkActive="mat-elevation-z1">Alle User</button> <div class="nav-button" routerLink="/users" routerLinkActive="active-link"><mat-icon>user_attributes</mat-icon>Alle User</div>
} }
<button matButton (click)="openSidebar()">Einstellungen</button> <div class="nav-button" (click)="openSidebar()"><mat-icon>settings</mat-icon>Einstellungen</div>
</mat-drawer> </mat-drawer>
@@ -32,6 +32,4 @@
</button> </button>
</div> --> </div> -->
</mat-drawer-container> </mat-drawer-container>
<app-settings #settings/>

View File

@@ -16,6 +16,10 @@ mat-drawer-container {
cursor: pointer; cursor: pointer;
} }
mat-toolbar {
border-bottom: 1px solid #ccc;
}
mat-drawer, mat-toolbar { mat-drawer, mat-toolbar {
background-color: #fff; background-color: #fff;
border-radius: 0; border-radius: 0;
@@ -34,4 +38,20 @@ mat-drawer {
mat-toolbar { mat-toolbar {
gap: 12px; gap: 12px;
}
.nav-button {
display: flex;
padding: 12px 12px;
margin: 4px;
border-radius: 8px;
cursor: pointer;
align-items: center;
gap: 12px;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: rgb(246, 246, 247);
}
} }

View File

@@ -5,21 +5,27 @@ import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { SettingsComponent } from '../../modules/settings/settings.component'; import { SettingsComponent } from '../../modules/settings/settings.component';
@Component({ @Component({
selector: 'app-layout', selector: 'app-layout',
imports: [MatButtonModule, MatIconModule, MatSidenavModule, RouterModule, MatToolbarModule, SettingsComponent], imports: [MatButtonModule, MatIconModule, MatSidenavModule, RouterModule, MatToolbarModule, MatDialogModule],
templateUrl: './layout.component.html', templateUrl: './layout.component.html',
styleUrl: './layout.component.scss' styleUrl: './layout.component.scss'
}) })
export class LayoutComponent { export class LayoutComponent {
private authService: AuthService = inject(AuthService); private authService: AuthService = inject(AuthService);
@ViewChild('settings') settings!: SettingsComponent;
private dialog: MatDialog = inject(MatDialog);
openSidebar() { openSidebar() {
console.log(this.settings) this.dialog.open(SettingsComponent, {
this.settings.open(); maxWidth: "calc(100vw - 48px)",
width: "600px",
minWidth: "200px",
disableClose: true,
})
} }
logout(){ logout(){

View File

@@ -0,0 +1,6 @@
export interface ICustomer {
id: string;
name: string;
createdAt: string;
updatetAt: String;
}

View File

@@ -3,10 +3,12 @@ import { IKey } from "./key.interface";
export interface ICylinder { export interface ICylinder {
id: string; id: string;
name: string; name: string;
description: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string; deletedAt: string;
system: any; system: any;
keys: IKey[]; keys: IKey[];
keyCount: number; keyCount: number;
digital: boolean;
} }

View File

@@ -10,4 +10,5 @@ export interface IKey {
nr: number; nr: number;
deletedAt?: string; deletedAt?: string;
keyLost: Date | null; keyLost: Date | null;
digital: boolean;
} }

View File

@@ -0,0 +1,7 @@
export interface ISystem {
id: string;
createdAt: string;
updatedAt: string;
deletedAt?: string;
name: string;
}

View File

@@ -1,7 +1,8 @@
@if (gridOptions || true) { @if (myTheme && gridOptions) {
<ag-grid-angular <ag-grid-angular
style="width: 100%; height: 100%;" style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)" (gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!" [gridOptions]="gridOptions!"
[theme]="myTheme"
/> />
} }

View File

@@ -8,6 +8,7 @@ import { AuthService } from '../../../core/auth/auth.service';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { AgGridContainerComponent } from '../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
@Component({ @Component({
selector: 'app-all-users', selector: 'app-all-users',
@@ -16,7 +17,7 @@ import { MatButtonModule } from '@angular/material/button';
templateUrl: './all-users.component.html', templateUrl: './all-users.component.html',
styleUrl: './all-users.component.scss' styleUrl: './all-users.component.scss'
}) })
export class AllUsersComponent { export class AllUsersComponent extends AgGridContainerComponent {
private toast: HotToastService = inject(HotToastService); private toast: HotToastService = inject(HotToastService);
private api: ApiService = inject(ApiService); private api: ApiService = inject(ApiService);
@@ -79,7 +80,7 @@ export class AllUsersComponent {
children: [ children: [
{ columnGroupShow: "closed", width: 180 , cellRenderer: 'agCheckboxCellRenderer', valueGetter: (data: any) => { return Object.values(data.data.settings).filter(v => typeof v == 'boolean').some((x: any) => x)}, type: 'boolean' }, { columnGroupShow: "closed", width: 180 , cellRenderer: 'agCheckboxCellRenderer', valueGetter: (data: any) => { return Object.values(data.data.settings).filter(v => typeof v == 'boolean').some((x: any) => x)}, type: 'boolean' },
{ field: 'settings.sendSystemAccessMails', headerName: 'Schlüssesystemzugriff', editable: true, columnGroupShow: "open" }, { field: 'settings.sendSystemAccessMails', headerName: 'Schlüssesystemzugriff', editable: true, columnGroupShow: "open" },
{ field: 'settings.sendSystemUpdateMails', headerName: 'Schließsystemupdates', editable: true, columnGroupShow: "open" }, { field: 'settings.sendSystemUpdateMails', headerName: 'Schließanlageupdates', editable: true, columnGroupShow: "open" },
{ field: 'settings.sendUserDisabledMails', headerName: 'User deaktiviert', editable: true, columnGroupShow: "open" } { field: 'settings.sendUserDisabledMails', headerName: 'User deaktiviert', editable: true, columnGroupShow: "open" }
] ]
}, },

View File

@@ -1,10 +1,11 @@
<h2 mat-dialog-title>Neuen Zylinder anlegen</h2> <h2 mat-dialog-title>Neuen Zylinder anlegen</h2>
<mat-dialog-content> <mat-dialog-content>
<div class="mat-body" style="margin-bottom: 24px;">Hier können Zylinder angelegt werden. Jeder Zylinder muss genau einer Schließanlage zugeordnet werden. Es können mehrere Schlüssel zu einem Zylinder zugeordnet werden.</div>
<form [formGroup]="createForm" class="flex flex-col gap-3"> <form [formGroup]="createForm" class="flex flex-col gap-3">
<mat-form-field> <mat-form-field>
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<input type="text" matInput formControlName="name" maxlength="100"> <input type="text" matInput formControlName="name" maxlength="100" placeholder="Bsp.: Haustür Ferienhaus">
@if ((createForm.controls.name.value || '').length > 20) { @if ((createForm.controls.name.value || '').length > 20) {
<mat-hint>{{ (createForm.controls.name.value || '').length }} / 100 Zeichen</mat-hint> <mat-hint>{{ (createForm.controls.name.value || '').length }} / 100 Zeichen</mat-hint>
} @else { } @else {
@@ -12,6 +13,18 @@
} }
</mat-form-field> </mat-form-field>
<div class="flex items-center gap-6">
<mat-form-field class="flex-auto">
<mat-label>Beschreibung</mat-label>
<input type="text" matInput formControlName="description" maxlength="255" placeholder="Bsp.: 30/30">
<mat-hint>Zylinderlänge und co.</mat-hint>
</mat-form-field>
<mat-checkbox formControlName="digital">Digitales Schloss</mat-checkbox>
</div>
<mat-form-field> <mat-form-field>
<mat-label>Schließanlage</mat-label> <mat-label>Schließanlage</mat-label>
<mat-select formControlName="system"> <mat-select formControlName="system">
@@ -20,8 +33,11 @@
} }
</mat-select> </mat-select>
<mat-hint>Zu welcher Schließanlage gehört der Zylinder?</mat-hint> <mat-hint>Zu welcher Schließanlage gehört der Zylinder?</mat-hint>
@if (isLoading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
</mat-form-field> </mat-form-field>
</form> </form>
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>

View File

@@ -9,10 +9,13 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { CommonModule } from '@angular/common';
import { MatCheckboxModule } from '@angular/material/checkbox';
@Component({ @Component({
selector: 'app-create-cylinder', selector: 'app-create-cylinder',
imports: [MatFormFieldModule, MatInputModule, MatDialogModule, ReactiveFormsModule, FormsModule, MatSelectModule, MatButtonModule, MatIconModule], imports: [MatFormFieldModule, MatInputModule, MatDialogModule, ReactiveFormsModule, FormsModule, MatSelectModule, MatButtonModule, MatIconModule, MatProgressBarModule, CommonModule, MatCheckboxModule],
templateUrl: './create-cylinder.component.html', templateUrl: './create-cylinder.component.html',
styleUrl: './create-cylinder.component.scss' styleUrl: './create-cylinder.component.scss'
}) })
@@ -22,18 +25,28 @@ export class CreateCylinderComponent {
readonly dialogRef = inject(MatDialogRef<CreateCylinderComponent>); readonly dialogRef = inject(MatDialogRef<CreateCylinderComponent>);
systems: any[] = []; systems: any[] = [];
isLoading = true;
createForm = new FormGroup({ createForm = new FormGroup({
name: new FormControl<string | null>(null, Validators.required), name: new FormControl<string | null>(null, Validators.required),
system: new FormControl<any>(null, Validators.required) system: new FormControl<any>(null, Validators.required),
description: new FormControl<string | null>(null),
digital: new FormControl<boolean>(false)
}); });
ngOnInit() { ngOnInit() {
this.api.getSystems().subscribe({ this.api.systems.asObservable().subscribe({
next: systems => { next: systems => {
this.systems = systems; this.systems = systems;
} }
}); });
this.loadCylinders();
}
private async loadCylinders() {
this.isLoading = true;
await this.api.refreshSystems();
this.isLoading = false;
} }
save() { save() {

View File

@@ -0,0 +1,15 @@
<h2 mat-dialog-title>Gelöschte Zylinder</h2>
<mat-dialog-content>
@if(myTheme && gridOptions) {
<ag-grid-angular
style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!"
[theme]="myTheme"
/>
}
</mat-dialog-content>
<mat-dialog-actions>
<button matButton mat-dialog-close>Schließen</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,78 @@
import { Component, inject, LOCALE_ID } from '@angular/core';
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
import { CommonModule, DatePipe } from '@angular/common';
import { HotToastService } from '@ngxpert/hot-toast';
import { ApiService } from '../../../../shared/api.service';
import { GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
import { HELPER } from '../../../../shared/helper.service';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { AgGridAngular } from 'ag-grid-angular';
import { ICylinder } from '../../../../model/interface/cylinder.interface';
@Component({
selector: 'app-cylinder-archive',
imports: [MatDialogModule, AgGridAngular, MatButtonModule, MatIconModule, CommonModule],
providers: [DatePipe, { provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './cylinder-archive.component.html',
styleUrl: './cylinder-archive.component.scss',
})
export class CylinderArchiveComponent extends AgGridContainerComponent {
private api: ApiService = inject(ApiService);
private datePipe = inject(DatePipe);
private toast = inject(HotToastService);
gridApi!: GridApi;
gridOptions: GridOptions = HELPER.getGridOptions();
constructor() {
super();
this.createGridOptions();
}
private createGridOptions() {
this.gridOptions.columnDefs = [
{ colId: 'name', field: 'name' , headerName: 'Name', flex: 1, editable: true, sort: 'asc', filter: true },
{ colId: 'nr', field: 'nr' , headerName: 'Name', flex: 1, editable: true, filter: true },
{
field: 'deletedAt'
, headerName: 'Gelöscht'
, width: 160
, cellRenderer: (data: any) => this.datePipe.transform(new Date(data.value), 'short')
},
{
width: 40,
cellRenderer: () => '<div class="icon-btn-sm restore icon-btn-xs" ></div>',
onCellClicked: (event) => { this.restoreCylinder(event.data);},
tooltipValueGetter: () => 'Wiederherstellen',
sortable: false
}
];
this.gridOptions.rowHeight = 36;
this.gridOptions.overlayNoRowsTemplate = 'Bisher wurden keine Zylinder gelöscht. Sobald dies der Fall ist, werden sie hier angezeigt.';
}
async restoreCylinder(data: ICylinder) {
this.gridApi.setGridOption("loading", true);
await this.api.restoreCylinder(data);
this.loadCylinders();
}
onGridReady(params: GridReadyEvent) {
this.gridApi = params.api;
this.loadCylinders();
}
async loadCylinders() {
this.gridApi.setGridOption("loading", true);
const cylinders = await this.api.getCylinderArchive();
this.gridApi.setGridOption("rowData", cylinders);
this.gridApi.setGridOption("loading", false);
}
}

View File

@@ -1,9 +1,12 @@
<ag-grid-angular @if (myTheme) {
<ag-grid-angular
style="width: 100%; height: 100%;" style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)" (gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!" [gridOptions]="gridOptions!"
[theme]="myTheme"
/> />
}
<div class="floating-btn-container"> <div class="floating-btn-container">
<button mat-flat-button class="btn-create mat-elevation-z8" (click)="openCreateCylinder()" >Zylinder anlegen</button> <button mat-flat-button class="btn-create mat-elevation-z8" (click)="openCreateCylinder()" >Zylinder anlegen</button>
<button mat-mini-fab disabled><mat-icon>inventory_2</mat-icon></button> <button mat-mini-fab (click)="openArchive()"><mat-icon>inventory_2</mat-icon></button>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { HELPER } from '../../shared/helper.service'; import { HELPER } from '../../shared/helper.service';
import { GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'; import { CellEditingStoppedEvent, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
import { AgGridAngular } from 'ag-grid-angular'; import { AgGridAngular } from 'ag-grid-angular';
import { ApiService } from '../../shared/api.service'; import { ApiService } from '../../shared/api.service';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
@@ -9,6 +9,9 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { CreateCylinderComponent } from './components/create-cylinder/create-cylinder.component'; import { CreateCylinderComponent } from './components/create-cylinder/create-cylinder.component';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { AgGridContainerComponent } from '../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
import { CylinderArchiveComponent } from './components/cylinder-archive/cylinder-archive.component';
import { ICylinder } from '../../model/interface/cylinder.interface';
@Component({ @Component({
selector: 'app-cylinder', selector: 'app-cylinder',
@@ -17,7 +20,7 @@ import { MatButtonModule } from '@angular/material/button';
templateUrl: './cylinder.component.html', templateUrl: './cylinder.component.html',
styleUrl: './cylinder.component.scss' styleUrl: './cylinder.component.scss'
}) })
export class CylinderComponent { export class CylinderComponent extends AgGridContainerComponent {
private api: ApiService = inject(ApiService); private api: ApiService = inject(ApiService);
private datePipe = inject(DatePipe); private datePipe = inject(DatePipe);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
@@ -28,9 +31,11 @@ export class CylinderComponent {
constructor() { constructor() {
super();
this.gridOptions.columnDefs = [ this.gridOptions.columnDefs = [
{ field: 'name', headerName: 'Name', sort: 'asc', flex: 1, filter: true }, { field: 'name', headerName: 'Name', sort: 'asc', flex: 1, filter: true, editable: true },
{ field: 'description', headerName: 'Beschreibung', flex: 1, filter: true, editable: true },
{ field: 'system.name', headerName: 'System', flex: 1, filter: true }, { field: 'system.name', headerName: 'System', flex: 1, filter: true },
{ field: 'keyCount', headerName: 'Anzahl Schlüssel', flex: 0, type: 'number' }, { field: 'keyCount', headerName: 'Anzahl Schlüssel', flex: 0, type: 'number' },
{ field: 'createdAt', headerName: 'Angelegt', cellRenderer: (data: any) => data.value ? this.datePipe.transform(new Date(data.value)) : '-' }, { field: 'createdAt', headerName: 'Angelegt', cellRenderer: (data: any) => data.value ? this.datePipe.transform(new Date(data.value)) : '-' },
@@ -45,25 +50,37 @@ export class CylinderComponent {
} }
loadCylinders() { loadCylinders() {
this.api.getCylinders().subscribe({ this.gridApi.setGridOption("loading", true);
next: n => { this.api.refreshCylinders();
this.gridApi.setGridOption("rowData", n); }
onGridReady(params: GridReadyEvent) {
this.gridApi = params.api;
this.gridApi.addEventListener("cellEditingStopped", evt => this.cellEditEnd(evt));
this.loadCylinders();
this.api.cylinders.asObservable().subscribe({
next: (data) => {
this.gridApi.setGridOption("rowData", data);
this.gridApi.setGridOption("loading", false); this.gridApi.setGridOption("loading", false);
} }
}) })
} }
onGridReady(params: GridReadyEvent) { private async cellEditEnd(event: CellEditingStoppedEvent) {
this.gridApi = params.api; const cylinder: ICylinder = event.data;
this.loadCylinders();
if (!event.valueChanged || event.newValue == event.oldValue) { return; }
await this.api.updateCylinder(cylinder)
} }
openCreateCylinder() { openCreateCylinder() {
this.dialog.open(CreateCylinderComponent, { this.dialog.open(CreateCylinderComponent, {
maxWidth: "calc(100vw - 24px)", maxWidth: "calc(100vw - 48px)",
width: "30vw", width: "800px",
minWidth: "200px", minWidth: "200px",
disableClose: true disableClose: true,
}).afterClosed().subscribe({ }).afterClosed().subscribe({
next: (cylinder) => { next: (cylinder) => {
if (cylinder) { if (cylinder) {
@@ -72,4 +89,14 @@ export class CylinderComponent {
} }
}); });
} }
openArchive() {
this.dialog.open(CylinderArchiveComponent, {
maxHeight: "calc(100vh - 48px)",
maxWidth: "calc(100vw - 48px)",
width: "800px",
minWidth: "min(700px,calc(100vw - 24px))",
height: "70vh",
})
}
} }

View File

@@ -59,7 +59,7 @@
<p>Derzeit ausgegebene Schlüssel</p> <p>Derzeit ausgegebene Schlüssel</p>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
<button matButton routerLink="/keys">Verwalten</button> <button matButton routerLink="/keys" [queryParams]="{handedOut: true }">Verwalten</button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
</div> </div>
@@ -73,6 +73,7 @@
style="width: 100%; height: 100%;" style="width: 100%; height: 100%;"
[gridOptions]="gridOptions" [gridOptions]="gridOptions"
(gridReady)="onGridReady($event)" (gridReady)="onGridReady($event)"
[theme]="myTheme"
> >
</ag-grid-angular> </ag-grid-angular>
</mat-card-content> </mat-card-content>

View File

@@ -9,6 +9,7 @@ import { RouterModule } from '@angular/router';
import { AgLoadingComponent } from '../../shared/ag-grid/components/ag-loading/ag-loading.component'; import { AgLoadingComponent } from '../../shared/ag-grid/components/ag-loading/ag-loading.component';
import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { AgGridContainerComponent } from '../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
@@ -17,7 +18,7 @@ import { MatButtonModule } from '@angular/material/button';
templateUrl: './dashboard.component.html', templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss' styleUrl: './dashboard.component.scss'
}) })
export class DashboardComponent { export class DashboardComponent extends AgGridContainerComponent {
private api = inject(ApiService); private api = inject(ApiService);
private datePipe = inject(DatePipe); private datePipe = inject(DatePipe);

View File

@@ -1,10 +1,13 @@
<h2 mat-dialog-title>Gelöschte Schlüssel</h2> <h2 mat-dialog-title>Gelöschte Schlüssel</h2>
<mat-dialog-content> <mat-dialog-content>
<ag-grid-angular @if(myTheme) {
<ag-grid-angular
style="width: 100%; height: 100%;" style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)" (gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!" [gridOptions]="gridOptions!"
[theme]="myTheme"
/> />
}
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>

View File

@@ -11,6 +11,7 @@ import { IKey } from '../../../../model/interface/key.interface';
import { HotToastService } from '@ngxpert/hot-toast'; import { HotToastService } from '@ngxpert/hot-toast';
import { AgLoadingComponent } from '../../../../shared/ag-grid/components/ag-loading/ag-loading.component'; import { AgLoadingComponent } from '../../../../shared/ag-grid/components/ag-loading/ag-loading.component';
import { HELPER } from '../../../../shared/helper.service'; import { HELPER } from '../../../../shared/helper.service';
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
@Component({ @Component({
selector: 'app-archive', selector: 'app-archive',
@@ -19,7 +20,7 @@ import { HELPER } from '../../../../shared/helper.service';
templateUrl: './archive.component.html', templateUrl: './archive.component.html',
styleUrl: './archive.component.scss' styleUrl: './archive.component.scss'
}) })
export class ArchiveComponent { export class ArchiveComponent extends AgGridContainerComponent {
private api: ApiService = inject(ApiService); private api: ApiService = inject(ApiService);
private datePipe = inject(DatePipe); private datePipe = inject(DatePipe);
private toast = inject(HotToastService); private toast = inject(HotToastService);
@@ -31,6 +32,7 @@ export class ArchiveComponent {
gridOptions: GridOptions = HELPER.getGridOptions(); gridOptions: GridOptions = HELPER.getGridOptions();
constructor() { constructor() {
super();
this.gridOptions.columnDefs = [ this.gridOptions.columnDefs = [
{ colId: 'name', field: 'name' , headerName: 'Name', flex: 1, editable: true, sort: 'asc', filter: true }, { colId: 'name', field: 'name' , headerName: 'Name', flex: 1, editable: true, sort: 'asc', filter: true },
{ colId: 'nr', field: 'nr' , headerName: 'Schlüsselnummer', flex: 1, editable: true, filter: true }, { colId: 'nr', field: 'nr' , headerName: 'Schlüsselnummer', flex: 1, editable: true, filter: true },
@@ -87,6 +89,9 @@ export class ArchiveComponent {
}, },
error: () => { error: () => {
this.gridApi.setGridOption("loading", false); this.gridApi.setGridOption("loading", false);
},
complete: () => {
this.api.refreshKeys();
} }
}); });
} }

View File

@@ -0,0 +1,20 @@
<h2 mat-dialog-title>Schlüssel <u>{{key.name}}</u> löschen?</h2>
<mat-dialog-content>
<div class="warning-message">
<mat-icon>warning</mat-icon>
<p>
<b>{{key.name}}</b> wirklich endgültig löschen?
</p>
<p class="additional-info">
<!-- Additional information -->
<small>Gelöschte Schlüssel können nicht mehr wiederhergestellt werden</small>
</p>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button matButton [mat-dialog-close]="false">Abbrechen</button>
<button matButton="elevated" [mat-dialog-close]="true" class="btn-warning">
<mat-icon>delete</mat-icon>
Entfernen
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,18 @@
import { Component, inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { IKey } from '../../../../../model/interface/key.interface';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-delete-key-from-archive',
imports: [MatDialogModule, MatButtonModule, MatIconModule],
templateUrl: './delete-key-from-archive.component.html',
styleUrl: './delete-key-from-archive.component.scss',
})
export class DeleteKeyFromArchiveComponent {
readonly dialogRef = inject(MatDialogRef<DeleteKeyFromArchiveComponent>);
readonly key = inject<IKey>(MAT_DIALOG_DATA);
}

View File

@@ -12,7 +12,7 @@
<!-- <h6>Neue Übergabe anlegen:</h6> --> <!-- <h6>Neue Übergabe anlegen:</h6> -->
<mat-form-field> <mat-form-field>
<mat-label>Kunde</mat-label> <mat-label>Mieter</mat-label>
<input type="text" <input type="text"
matInput matInput
formControlName="customer" formControlName="customer"
@@ -36,7 +36,7 @@
<mat-form-field> <mat-form-field>
<mat-label>Datum der Übergabe</mat-label> <mat-label>Datum der Übergabe</mat-label>
<input matInput [matDatepicker]="picker" formControlName="timestamp"> <input matInput [matDatepicker]="picker" formControlName="timestamp">
<mat-hint>TT/MM/JJJJ</mat-hint> <mat-hint>TT.MM.JJJJ (Beispiel: {{ exampleDate | date:'dd.MM.yyyy'}})</mat-hint>
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle> <mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker> <mat-datepicker #picker></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -44,7 +44,7 @@
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<button matButton mat-dialog-close class="btn-warning">Schließen</button> <button matButton mat-dialog-close class="btn-warning">Schließen</button>
<button matButton="elevated" (click)="save()" class="btn-primary" [disabled]="handoverForm.invalid || handoverForm.pristine"> <button matButton="elevated" (click)="save()" class="btn-primary" [disabled]="handoverForm.invalid">
<mat-icon>save</mat-icon> <mat-icon>save</mat-icon>
Speichern Speichern
</button> </button>
@@ -58,6 +58,7 @@
style="width: 100%; height: 100%;" style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)" (gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!" [gridOptions]="gridOptions!"
[theme]="myTheme"
/> />
</div> </div>
</mat-dialog-content> </mat-dialog-content>

View File

@@ -10,7 +10,7 @@ import { MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/cor
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { AsyncPipe, DatePipe } from '@angular/common'; import { AsyncPipe, DatePipe } from '@angular/common';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { map, Observable, startWith, timestamp } from 'rxjs'; import { from, map, Observable, startWith, timestamp } from 'rxjs';
import { import {
MatBottomSheet, MatBottomSheet,
MatBottomSheetModule, MatBottomSheetModule,
@@ -25,10 +25,12 @@ import { GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale'; import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale';
import { AgGridAngular } from 'ag-grid-angular'; import { AgGridAngular } from 'ag-grid-angular';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
import { ICustomer } from '../../../../model/interface/customer.interface';
@Component({ @Component({
selector: 'app-handover-dialog', selector: 'app-handover-dialog',
imports: [FormsModule, MatTabsModule, AgGridAngular, ReactiveFormsModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatDialogModule, MatAutocompleteModule, MatProgressSpinnerModule, MatRadioModule, AsyncPipe, MatIconModule], imports: [FormsModule, MatTabsModule, AgGridAngular, ReactiveFormsModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatDialogModule, MatAutocompleteModule, MatProgressSpinnerModule, MatRadioModule, AsyncPipe, MatIconModule, DatePipe],
providers: [ providers: [
provideNativeDateAdapter(), provideNativeDateAdapter(),
{ provide: LOCALE_ID, useValue: 'de-DE' }, { provide: LOCALE_ID, useValue: 'de-DE' },
@@ -38,7 +40,7 @@ import { MatIconModule } from '@angular/material/icon';
templateUrl: './handover-dialog.component.html', templateUrl: './handover-dialog.component.html',
styleUrl: './handover-dialog.component.scss' styleUrl: './handover-dialog.component.scss'
}) })
export class HandoverDialogComponent { export class HandoverDialogComponent extends AgGridContainerComponent {
private api: ApiService = inject(ApiService); private api: ApiService = inject(ApiService);
readonly dialogRef = inject(MatDialogRef<HandoverDialogComponent>); readonly dialogRef = inject(MatDialogRef<HandoverDialogComponent>);
@@ -47,13 +49,15 @@ export class HandoverDialogComponent {
private datePipe = inject(DatePipe); private datePipe = inject(DatePipe);
private toast: HotToastService = inject(HotToastService); private toast: HotToastService = inject(HotToastService);
public exampleDate = new Date();
gridApi!: GridApi; gridApi!: GridApi;
gridOptions: GridOptions = { gridOptions: GridOptions = {
localeText: AG_GRID_LOCALE_DE, localeText: AG_GRID_LOCALE_DE,
rowData: [], rowData: [],
isRowSelectable: () => false, isRowSelectable: () => false,
columnDefs: [ columnDefs: [
{ colId: 'customer', field: 'customer.name' , headerName: 'Kunde', flex: 1, editable: false, filter: false}, { colId: 'customer', field: 'customer.name' , headerName: 'Mieter', flex: 1, editable: false, filter: false},
{ {
colId: 'handedOut', colId: 'handedOut',
field: 'direction' , field: 'direction' ,
@@ -79,8 +83,8 @@ export class HandoverDialogComponent {
isLoading: boolean = false; isLoading: boolean = false;
customers: { name: string, id: string }[] = []; customers: ICustomer[] = [];
filteredCustomers: Observable<any[]> = new Observable(); filteredCustomers: Observable<ICustomer[]> = new Observable();
handoverForm = new FormGroup({ handoverForm = new FormGroup({
customer: new FormControl<any>(null, Validators.required), customer: new FormControl<any>(null, Validators.required),
@@ -97,7 +101,7 @@ export class HandoverDialogComponent {
this.isLoading = true; this.isLoading = true;
const promises: Observable<any>[] = [ const promises: Observable<any>[] = [
this.getHandovers(), this.getHandovers(),
this.loadCustomers() from(this.loadCustomers())
]; ];
Promise.all(promises).then(() => { Promise.all(promises).then(() => {
@@ -122,21 +126,14 @@ export class HandoverDialogComponent {
return promise; return promise;
} }
loadCustomers() { async loadCustomers() {
const promise = this.api.getCustomers() const customers = await this.api.refreshCustomers()
this.customers = customers;
promise.subscribe({ this.filteredCustomers = this.handoverForm.controls.customer.valueChanges.pipe(
next: customers => { startWith(''),
this.customers = customers; map(value => this._filter(value || '')),
this.filteredCustomers = this.handoverForm.controls.customer.valueChanges.pipe( );
startWith(''), return Promise.resolve()
map(value => this._filter(value || '')),
);
}
});
return promise;
} }
private _filter(value: string): any[] { private _filter(value: string): any[] {

View File

@@ -1,10 +1,13 @@
<h2 mat-dialog-title>Verlorene Schlüssel</h2> <h2 mat-dialog-title>Verlorene Schlüssel</h2>
<mat-dialog-content> <mat-dialog-content>
<ag-grid-angular @if(myTheme) {
<ag-grid-angular
style="width: 100%; height: 100%;" style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)" (gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!" [gridOptions]="gridOptions!"
[theme]="myTheme"
/> />
}
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<button matButton [mat-dialog-close]="dataChanged">Schließen</button> <button matButton [mat-dialog-close]="dataChanged">Schließen</button>

View File

@@ -10,6 +10,7 @@ import { HELPER } from '../../../../shared/helper.service';
import { AgGridAngular } from 'ag-grid-angular'; import { AgGridAngular } from 'ag-grid-angular';
import { LostKeyComponent } from '../lost-key/lost-key.component'; import { LostKeyComponent } from '../lost-key/lost-key.component';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
@Component({ @Component({
selector: 'app-lost-keys', selector: 'app-lost-keys',
@@ -18,7 +19,7 @@ import { MatButtonModule } from '@angular/material/button';
templateUrl: './lost-keys.component.html', templateUrl: './lost-keys.component.html',
styleUrl: './lost-keys.component.scss' styleUrl: './lost-keys.component.scss'
}) })
export class LostKeysComponent { export class LostKeysComponent extends AgGridContainerComponent {
private api: ApiService = inject(ApiService); private api: ApiService = inject(ApiService);
private datePipe = inject(DatePipe); private datePipe = inject(DatePipe);
private dialog: MatDialog = inject(MatDialog); private dialog: MatDialog = inject(MatDialog);
@@ -31,6 +32,7 @@ export class LostKeysComponent {
gridOptions: GridOptions = HELPER.getGridOptions(); gridOptions: GridOptions = HELPER.getGridOptions();
constructor() { constructor() {
super();
this.gridOptions.columnDefs = [ this.gridOptions.columnDefs = [
{ colId: 'name', field: 'name', headerName: 'Name', sort: 'asc', flex: 1, filter: true }, { colId: 'name', field: 'name', headerName: 'Name', sort: 'asc', flex: 1, filter: true },
{ colId: 'nr', field: 'nr', headerName: 'Schlüsselnummer', flex: 1, filter: true }, { colId: 'nr', field: 'nr', headerName: 'Schlüsselnummer', flex: 1, filter: true },
@@ -38,7 +40,7 @@ export class LostKeysComponent {
cellRenderer: (data: any) => data.value?.map((m: ICylinder) => m.name).join(', ') cellRenderer: (data: any) => data.value?.map((m: ICylinder) => m.name).join(', ')
}, },
{ {
colId: 'customer', field: 'customer.name', headerName: 'Kunde', flex: 1, filter: true, colId: 'customer', field: 'customer.name', headerName: 'Mieter', flex: 1, filter: true,
}, },
{ colId: 'keyLost', field: 'keyLost', headerName: 'Verloren seit', width: 100, { colId: 'keyLost', field: 'keyLost', headerName: 'Verloren seit', width: 100,
cellRenderer: (data: any) => this.datePipe.transform(new Date(data.value), 'dd.MM.yyyy'), cellRenderer: (data: any) => this.datePipe.transform(new Date(data.value), 'dd.MM.yyyy'),
@@ -76,15 +78,11 @@ export class LostKeysComponent {
markAsFound(key: IKey) { markAsFound(key: IKey) {
this.dialog.open(LostKeyComponent, { data: key, autoFocus: false }).afterClosed().subscribe({ this.dialog.open(LostKeyComponent, { data: key, autoFocus: false }).afterClosed().subscribe({
next: (result) => { next: async (result) => {
if (result == "") { if (result == "") {
key.keyLost = null; key.keyLost = null;
this.api.updateKey(key).subscribe({ await this.api.updateKey(key);
next: () => { this.loadLostKeys();
this.toast.success('Schlüssel als gefunden markiert');
this.loadLostKeys();
}
});
this.dataChanged = true; this.dataChanged = true;
} }
} }

View File

@@ -4,19 +4,19 @@
<mat-form-field> <mat-form-field>
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<input type="text" matInput formControlName="name" maxlength="100"> <input type="text" matInput formControlName="name" maxlength="100" placeholder="Bsp.: Kellerschlüssel Ferienhaus">
@if ((createForm.controls.name.value || '').length > 20) { @if ((createForm.controls.name.value || '').length > 20) {
<mat-hint>{{ (createForm.controls.name.value || '').length }} / 100 Zeichen</mat-hint> <mat-hint>{{ (createForm.controls.name.value || '').length }} / 100 Zeichen</mat-hint>
} @else { } @else {
<mat-hint>Wie soll der Schlüssel heißen?</mat-hint> <mat-hint>Wie soll der Schlüssel heißen? Der Name beschreibt den Schlüssel. Er sollte aber nicht einem Mieter zugeordnet werden.</mat-hint>
} }
</mat-form-field> </mat-form-field>
<div class="flex items-center gap-6"> <div class="flex items-center gap-6">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Schlüsselnummer</mat-label> <mat-label>Schlüsselnummer</mat-label>
<input type="text" matInput formControlName="nr" maxlength="100"> <input type="text" matInput formControlName="nr" maxlength="100" placeholder="12 - R115843">
<mat-hint>Nummer auf dem Schlüssel</mat-hint> <mat-hint>Die Nummer auf dem Schlüssel oder dem Chip.</mat-hint>
</mat-form-field> </mat-form-field>
<div> <div>
@@ -29,12 +29,12 @@
<mat-label>Schließzylinder</mat-label> <mat-label>Schließzylinder</mat-label>
<mat-select formControlName="cylinder" multiple> <mat-select formControlName="cylinder" multiple>
@for (item of cylinders; track $index) { @for (item of cylinders; track $index) {
<mat-option [value]="item">{{ item.name }}</mat-option> <mat-option [value]="item">{{ item.name }} ({{item.system.name}}) </mat-option>
} }
</mat-select> </mat-select>
<mat-hint>Wo sperrt der Schlüssel?</mat-hint> <mat-hint>Wo sperrt der Schlüssel?</mat-hint>
</mat-form-field> </mat-form-field>
<button mat-icon-button (click)="openSelectMultipleCylinders()" style="margin-bottom: 12px;"> <button mat-icon-button (click)="openSelectMultipleCylinders()" style="margin-bottom: 12px;" matTooltip="Zylinderauswahl in neuem Fenster öffnen">
<mat-icon>open_in_new</mat-icon> <mat-icon>open_in_new</mat-icon>
</button> </button>
</div> </div>

View File

@@ -14,10 +14,11 @@ import { MatIconModule } from '@angular/material/icon';
import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatCheckboxModule} from '@angular/material/checkbox';
import { IKey } from '../../../model/interface/key.interface'; import { IKey } from '../../../model/interface/key.interface';
import { ICylinder } from '../../../model/interface/cylinder.interface'; import { ICylinder } from '../../../model/interface/cylinder.interface';
import { MatTooltipModule } from '@angular/material/tooltip';
@Component({ @Component({
selector: 'app-create', selector: 'app-create',
imports: [MatDialogModule, MatButtonModule, ReactiveFormsModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatDialogModule, MatIconModule, MatCheckboxModule], imports: [MatDialogModule, MatButtonModule, ReactiveFormsModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatDialogModule, MatIconModule, MatCheckboxModule, MatTooltipModule],
templateUrl: './create.component.html', templateUrl: './create.component.html',
styleUrl: './create.component.scss' styleUrl: './create.component.scss'
}) })
@@ -44,6 +45,12 @@ export class CreateKeyComponent {
} }
async doSetup() { async doSetup() {
this.api.cylinders.subscribe({
next: data => {
this.cylinders = data;
this.createForm.controls.cylinder.patchValue(null);
}
})
await this.loadCylinders(); await this.loadCylinders();
this.filteredCylinders = this.createForm.controls.cylinder.valueChanges.pipe( this.filteredCylinders = this.createForm.controls.cylinder.valueChanges.pipe(
@@ -63,15 +70,7 @@ export class CreateKeyComponent {
} }
loadCylinders() { loadCylinders() {
return new Promise(resolve => { return this.api.refreshCylinders();
this.api.getCylinders().subscribe({
next: n => {
this.cylinders = n;
this.createForm.controls.cylinder.patchValue(null);
resolve(null)
}
});
})
} }
@@ -94,9 +93,9 @@ export class CreateKeyComponent {
openSelectMultipleCylinders() { openSelectMultipleCylinders() {
this.dialog.open(SelectKeyCylinderComponent, { this.dialog.open(SelectKeyCylinderComponent, {
maxHeight: "calc(100vh - 24px)", maxHeight: "calc(100vh - 48px)",
maxWidth: "calc(100vw - 24px)", maxWidth: "calc(100vw - 48px)",
width: "50vw", width: "800px",
minWidth: "300px", minWidth: "300px",
height: "70vh", height: "70vh",
disableClose: true, disableClose: true,

View File

@@ -0,0 +1,23 @@
<h2 mat-dialog-title>Mehrere Schließanlagen gewählt!</h2>
<mat-dialog-content>
<div class="warning-message">
<mat-icon>warning</mat-icon>
<p>
Der Schlüssel ist Zylindern in mehreren Schließanlagen zugeordnet!
</p>
<p class="additional-info">
<!-- Additional information -->
<small>Zum Korrigieren abbrechen, ansonsten speichern</small>
</p>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button matButton [mat-dialog-close]="false">
<mat-icon>close</mat-icon>
Vorgang abbrechen
</button>
<button matButton="elevated" [mat-dialog-close]="true" class="btn-warning">
<mat-icon>check</mat-icon>
Schlüssel speichern
</button>
</mat-dialog-actions>

Some files were not shown because too many files have changed in this diff Show More