Multicylinders

This commit is contained in:
Bastian Wagner
2025-01-02 11:17:28 +01:00
parent 66302ff05a
commit bf64103369
12 changed files with 199 additions and 34 deletions

View File

@@ -4,6 +4,7 @@ import {
CreateDateColumn, CreateDateColumn,
DeleteDateColumn, DeleteDateColumn,
Entity, Entity,
ManyToMany,
ManyToOne, ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@@ -20,7 +21,7 @@ export class Cylinder {
@Column({ nullable: false, unique: true }) @Column({ nullable: false, unique: true })
name: string; name: string;
@OneToMany(() => Key, (key) => key.cylinder) @ManyToMany(() => Key, (key) => key.cylinder)
keys: Key[]; keys: Key[];
@ManyToOne(() => KeySystem, (sys) => sys.cylinders) @ManyToOne(() => KeySystem, (sys) => sys.cylinders)

View File

@@ -3,6 +3,8 @@ import {
CreateDateColumn, CreateDateColumn,
DeleteDateColumn, DeleteDateColumn,
Entity, Entity,
JoinTable,
ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
@@ -28,8 +30,9 @@ export class Key implements IKey {
@Column({ name: 'lost', default: false }) @Column({ name: 'lost', default: false })
keyLost: boolean; keyLost: boolean;
@ManyToOne(() => Cylinder, (cylinder) => cylinder.keys) @ManyToMany(() => Cylinder, (cylinder) => cylinder.keys)
cylinder: Cylinder; @JoinTable()
cylinder: Cylinder[];
@ManyToOne(() => Customer, (customer) => customer.keys) @ManyToOne(() => Customer, (customer) => customer.keys)
customer: Customer; customer: Customer;

View File

@@ -2,6 +2,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } from 'typeorm';
@@ -27,8 +28,8 @@ export class KeyActivity implements IKey {
@Column({ name: 'handed_out', default: false }) @Column({ name: 'handed_out', default: false })
handedOut: boolean; handedOut: boolean;
@ManyToOne(() => Cylinder) @ManyToMany(() => Cylinder)
cylinder: Cylinder; cylinder: Cylinder[];
@ManyToOne(() => Customer) @ManyToOne(() => Customer)
customer: Customer; customer: Customer;

View File

@@ -5,7 +5,7 @@ export interface IKey {
name: string; name: string;
nr: string; nr: string;
handedOut: boolean; handedOut: boolean;
cylinder: Cylinder; cylinder: Cylinder[];
customer: Customer; customer: Customer;
createdAt: Date; createdAt: Date;
} }

View File

@@ -2,10 +2,16 @@ import { map } from "rxjs";
jest.mock('@ngxpert/hot-toast', () => ({ jest.mock('@ngxpert/hot-toast', () => ({
HotToastService: { HotToastService: {
observe: jest.fn().mockImplementation(() => map(x => x)) observe: jest.fn().mockImplementation(() => map(x => x)),
info: jest.fn(),
error: jest.fn(),
success: jest.fn(),
}, },
})); }));
export class MockHotToastService { export class MockHotToastService {
observe = jest.fn().mockImplementation(() => map(x => x)) observe = jest.fn().mockImplementation(() => map(x => x));
info = jest.fn();
error = jest.fn();
success = jest.fn();
}; };

View File

@@ -18,18 +18,23 @@
<mat-hint>Nummer auf dem Schlüssel</mat-hint> <mat-hint>Nummer auf dem Schlüssel</mat-hint>
</mat-form-field> </mat-form-field>
<mat-form-field> <div class="flex items-center gap-3">
<mat-label>Schließzylinder</mat-label> <mat-form-field class="flex-auto">
<mat-select formControlName="cylinder"> <mat-label>Schließzylinder</mat-label>
@for (item of cylinders; track $index) { <mat-select formControlName="cylinder" multiple>
<mat-option [value]="item">{{ item.name }}</mat-option> @for (item of cylinders; track $index) {
} <mat-option [value]="item">{{ item.name }}</mat-option>
</mat-select> }
<mat-hint>Wo sperrt der Schlüssel?</mat-hint> </mat-select>
</mat-form-field> <mat-hint>Wo sperrt der Schlüssel?</mat-hint>
</mat-form-field>
<button mat-icon-button (click)="openSelectMultipleCylinders()">
<mat-icon>open_in_new</mat-icon>
</button>
</div>
</form> </form>
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<button mat-button mat-dialog-close >Abbrechen</button> <button mat-button mat-dialog-close >Abbrechen</button>
<button mat-button (click)="save()" [disabled]="createForm.disabled">Speichern</button> <button mat-button (click)="save()" [disabled]="createForm.disabled || createForm.invalid">Speichern</button>
</mat-dialog-actions> </mat-dialog-actions>

View File

@@ -1,7 +1,7 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { ApiService } from '../../../shared/api.service'; import { ApiService } from '../../../shared/api.service';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -9,11 +9,13 @@ import { MatInputModule } from '@angular/material/input';
import { map, Observable, startWith } from 'rxjs'; import { map, Observable, startWith } from 'rxjs';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { HotToastService } from '@ngxpert/hot-toast'; import { HotToastService } from '@ngxpert/hot-toast';
import { SelectKeyCylinderComponent } from './select-key-cylinder/select-key-cylinder.component';
import { MatIconModule } from '@angular/material/icon';
@Component({ @Component({
selector: 'app-create', selector: 'app-create',
standalone: true, standalone: true,
imports: [MatDialogModule, MatButtonModule, ReactiveFormsModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatDialogModule], imports: [MatDialogModule, MatButtonModule, ReactiveFormsModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatDialogModule, MatIconModule],
templateUrl: './create.component.html', templateUrl: './create.component.html',
styleUrl: './create.component.scss' styleUrl: './create.component.scss'
}) })
@@ -22,6 +24,7 @@ export class CreateKeyComponent {
private api: ApiService = inject(ApiService); private api: ApiService = inject(ApiService);
private toast: HotToastService = inject(HotToastService); private toast: HotToastService = inject(HotToastService);
readonly dialogRef = inject(MatDialogRef<CreateKeyComponent>); readonly dialogRef = inject(MatDialogRef<CreateKeyComponent>);
private readonly dialog = inject(MatDialog);
createForm = new FormGroup({ createForm = new FormGroup({
name: new FormControl(null, Validators.required), name: new FormControl(null, Validators.required),
@@ -57,6 +60,8 @@ export class CreateKeyComponent {
} }
save() { save() {
console.log(this.createForm.value)
this.api.createKey(this.createForm.value as any) this.api.createKey(this.createForm.value as any)
.pipe( .pipe(
this.toast.observe({ this.toast.observe({
@@ -72,4 +77,23 @@ export class CreateKeyComponent {
} }
}) })
} }
openSelectMultipleCylinders() {
this.dialog.open(SelectKeyCylinderComponent, {
maxHeight: "calc(100vh - 24px)",
maxWidth: "calc(100vw - 24px)",
width: "50vw",
minWidth: "300px",
height: "70vh",
disableClose: true,
data: this.cylinders
}).afterClosed().subscribe({
next: c => {
if (c) {
this.createForm.controls.cylinder.patchValue(c);
console.log(c);
}
}
})
}
} }

View File

@@ -0,0 +1,15 @@
<h2 mat-dialog-title>Zylinder auswählen</h2>
<mat-dialog-content>
<ag-grid-angular
style="width: 100%; height: 100%;"
(gridReady)="onGridReady($event)"
[gridOptions]="gridOptions!"
/>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button color="warn" [mat-dialog-close]="null">Abbrechen</button>
<button mat-button color="accent" [mat-dialog-close]="selectedCylinders">Übernehmen</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,37 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SelectKeyCylinderComponent } from './select-key-cylinder.component';
import { HotToastService } from '@ngxpert/hot-toast';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MockHotToastService } from '../../../../../../mocks/services/mock.hottoast.service';
describe('SelectKeyCylinderComponent', () => {
let component: SelectKeyCylinderComponent;
let fixture: ComponentFixture<SelectKeyCylinderComponent>;
const mockHotToastService = {
info: jest.fn(),
error: jest.fn(),
success: jest.fn(),
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SelectKeyCylinderComponent],
providers: [
{ provide: HotToastService, useClass: MockHotToastService },
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: [] }
]
})
.compileComponents();
fixture = TestBed.createComponent(SelectKeyCylinderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,54 @@
import { Component, inject } from '@angular/core';
import { ApiService } from '../../../../shared/api.service';
import { ICylinder } from '../../../../model/interface/cylinder.interface';
import { HotToastService } from '@ngxpert/hot-toast';
import { AgGridAngular } from 'ag-grid-angular';
import { GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community';
import { AG_GRID_LOCALE_DE } from '@ag-grid-community/locale';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-select-key-cylinder',
standalone: true,
imports: [AgGridAngular, MatDialogModule, MatButtonModule],
templateUrl: './select-key-cylinder.component.html',
styleUrl: './select-key-cylinder.component.scss'
})
export class SelectKeyCylinderComponent {
private toast: HotToastService = inject(HotToastService);
readonly dialogRef = inject(MatDialogRef<SelectKeyCylinderComponent>);
readonly cylinders = inject<ICylinder[]>(MAT_DIALOG_DATA);
gridApi!: GridApi;
gridOptions: GridOptions = {
localeText: AG_GRID_LOCALE_DE,
loading: false,
rowData: this.cylinders,
onSelectionChanged: (event) => {
this.selectionChanged();
},
rowSelection: 'multiple',
columnDefs: [
// selected rows
{ colId: 'selected', headerName: '', checkboxSelection: true, width: 40, headerCheckboxSelection: true, headerCheckboxSelectionFilteredOnly: true },
{ colId: 'name', field: 'name' , headerName: 'Name', flex: 1, editable: true, sort: 'asc', filter: true },
]
};
selectedCylinders: ICylinder[] = [];
ngOnInit(): void {
console.log(this.toast)
this.toast.error('Wähle die Zylinder aus, die dem Schlüssel zugeordnet werden sollen.');
}
onGridReady(params: GridReadyEvent) {
this.gridApi = params.api;
}
selectionChanged(): void {
this.selectedCylinders =this.gridApi?.getSelectedRows();
}
}

View File

@@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon';
import { ArchiveComponent } from './components/archive/archive.component'; import { ArchiveComponent } from './components/archive/archive.component';
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 { map, of } from 'rxjs'; import { map, of } from 'rxjs';
import { ICylinder } from '../../model/interface/cylinder.interface';
@Component({ @Component({
selector: 'app-keys', selector: 'app-keys',
@@ -40,18 +41,21 @@ export class KeysComponent {
{ 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 },
{ colId: 'cylinder', field: 'cylinder' , headerName: 'Zylinder', flex: 1, editable: true, filter: true, cellRenderer: (data: any) => {return data.value?.name}, cellEditor: 'agSelectCellEditor', { colId: 'cylinder', field: 'cylinder' , headerName: 'Zylinder', flex: 1, editable: false, filter: true, cellRenderer: (data: any) => {return data.value?.map((m: ICylinder) => m.name).join(', ')}, cellEditor: 'agSelectCellEditor',
cellEditorParams: () => { // cellEditorParams: () => {
return { // return {
values: this.cylinders, // values: this.cylinders,
} // }
}, // },
valueFormatter: (val) => { // valueFormatter: (val) => {
return val.value?.name + ` (${val.value?.system?.name})`; // return val.value?.name + ` (${val.value?.system?.name})`;
}, // },
cellEditorPopup: false cellEditorPopup: false
}, },
{ colId: 'system', field: 'cylinder.system' , headerName: 'Schließanlage', flex: 1, editable: false, filter: true, cellRenderer: (data: any) => {return data.value?.name} }, { colId: 'system', field: 'cylinder' , headerName: 'Schließanlage', flex: 1, editable: false, filter: true, cellRenderer: (data: any) => {
const s = new Set<string>(data.value?.map((m: ICylinder) => m.system?.name));
return [...s].join(', ')
} },
{ colId: 'customer', field: 'customer' , headerName: 'Kunde', flex: 1, editable: false, filter: true, cellRenderer: (data: any) => {return data.value?.name} }, { colId: 'customer', field: 'customer' , headerName: 'Kunde', flex: 1, editable: false, filter: true, cellRenderer: (data: any) => {return data.value?.name} },
{ {
field: 'createdAt' field: 'createdAt'
@@ -92,15 +96,25 @@ export class KeysComponent {
this.api.getCylinders().subscribe({ this.api.getCylinders().subscribe({
next: n => { next: n => {
this.cylinders = n; this.cylinders = n;
},
error: () => {
this.cylinders = [];
} }
}) })
} }
loadKeys() { loadKeys() {
this.gridApi.setGridOption("loading", true); this.gridApi.setGridOption("loading", true);
this.api.getKeys().subscribe(res => { this.api.getKeys().subscribe({
this.gridApi.setGridOption("rowData", res); next: n => {
this.gridApi.setGridOption("loading", false); this.gridApi.setGridOption("rowData", n);
this.gridApi.setGridOption("loading", false);
},
error: () => {
this.gridApi.setGridOption("loading", false);
// set error in grid
this.toast.error('Fehler beim Laden der Schlüssel')
}
}) })
} }
@@ -131,7 +145,12 @@ export class KeysComponent {
} }
openCreateKey() { openCreateKey() {
this.dialog.open(CreateKeyComponent).afterClosed().subscribe({ this.dialog.open(CreateKeyComponent, {
maxWidth: "calc(100vw - 24px)",
width: "30vw",
minWidth: "200px",
disableClose: true
}).afterClosed().subscribe({
next: key => { next: key => {
if (key) { if (key) {
let d = [...this.gridApi.getGridOption("rowData") || [], key]; let d = [...this.gridApi.getGridOption("rowData") || [], key];