Dashboard + CrudLiveList

This commit is contained in:
Patrick Haßel 2024-11-27 11:55:11 +01:00
parent bb2af44542
commit 73926b13e6
37 changed files with 356 additions and 54 deletions

View File

@ -27,4 +27,8 @@ export class Device {
return device.uuid; return device.uuid;
} }
static equals(a: Device, b: Device): boolean {
return a.uuid !== b.uuid;
}
} }

View File

@ -2,4 +2,10 @@ export class DeviceFilter {
search: string = ""; search: string = "";
stateTrue: boolean = true;
stateFalse: boolean = true;
stateNull: boolean = true;
} }

View File

@ -27,4 +27,8 @@ export class Shutter {
return shutter.uuid; return shutter.uuid;
} }
static equals(a: Shutter, b: Shutter): boolean {
return a.uuid !== b.uuid;
}
} }

View File

@ -2,4 +2,12 @@ export class ShutterFilter {
search: string = ""; search: string = "";
positionOpen: boolean | null = null;
positionBetween: boolean | null = null;
positionClosed: boolean | null = null;
stateNull: boolean | null = null;
} }

View File

@ -2,6 +2,7 @@ import {Property} from "../Property/Property";
import {orNull, validateString} from "../common/validators"; import {orNull, validateString} from "../common/validators";
export class Tunable { export class Tunable {
constructor( constructor(
readonly uuid: string, readonly uuid: string,
readonly name: string, readonly name: string,
@ -34,4 +35,8 @@ export class Tunable {
return tunable.uuid; return tunable.uuid;
} }
static equals(a: Tunable, b: Tunable): boolean {
return a.uuid !== b.uuid;
}
} }

View File

@ -2,4 +2,10 @@ export class TunableFilter {
search: string = ""; search: string = "";
stateTrue: boolean = true;
stateFalse: boolean = true;
stateNull: boolean = true;
} }

View File

@ -0,0 +1,42 @@
import {CrudService} from "./CrudService";
import {Subscription} from "rxjs";
export class CrudLiveList<ENTITY> extends Subscription {
private readonly subs: Subscription[] = [];
unfiltered: ENTITY[] = [];
filtered: ENTITY[] = [];
constructor(
crudService: CrudService<ENTITY>,
readonly equals: (a: ENTITY, b: ENTITY) => boolean,
readonly filter: (item: ENTITY) => boolean = _ => true,
) {
super(() => {
this.subs.forEach(sub => sub.unsubscribe());
});
crudService.all(list => this.unfiltered = list);
this.subs.push(crudService.subscribe(item => this.update(item)));
}
private update(item: ENTITY) {
const index = this.unfiltered.findIndex(i => this.equals(i, item));
if (index >= 0) {
this.unfiltered.splice(index, 1, item);
} else {
this.unfiltered.push(item);
}
this.filtered = this.unfiltered.filter(this.filter);
}
get hasUnfiltered(): boolean {
return this.unfiltered.length > 0;
}
get hasFiltered(): boolean {
return this.filtered.length > 0;
}
}

View File

@ -13,6 +13,10 @@ export abstract class CrudService<ENTITY> {
// //
} }
all(next: Next<ENTITY[]>) {
this.getList(['list'], next);
}
subscribe(next: Next<ENTITY>): Subscription { subscribe(next: Next<ENTITY>): Subscription {
return this.api.subscribe([...this.path], this.fromJson, next); return this.api.subscribe([...this.path], this.fromJson, next);
} }
@ -48,5 +52,4 @@ export abstract class CrudService<ENTITY> {
protected postPage(path: any[], data: any, next?: Next<Page<ENTITY>>): void { protected postPage(path: any[], data: any, next?: Next<Page<ENTITY>>): void {
this.api.postPage([...this.path, ...path], data, this.fromJson, next); this.api.postPage([...this.path, ...path], data, this.fromJson, next);
} }
} }

View File

@ -1,7 +1,8 @@
<div class="flexBox"> <div class="flexBox">
<div class="flexBoxFixed menu"> <div class="flexBoxFixed menu">
<div class="item itemLeft" routerLink="Dashboard" routerLinkActive="active">Dash</div>
<div class="item itemLeft" routerLink="DeviceList" routerLinkActive="active">Geräte</div> <div class="item itemLeft" routerLink="DeviceList" routerLinkActive="active">Geräte</div>
<div class="item itemLeft" routerLink="TunableList" routerLinkActive="active">Licht</div> <div class="item itemLeft" routerLink="TunableList" routerLinkActive="active">Lichter</div>
<div class="item itemLeft" routerLink="ShutterList" routerLinkActive="active">Rollläden</div> <div class="item itemLeft" routerLink="ShutterList" routerLinkActive="active">Rollläden</div>
<div class="item itemRight" routerLink="GroupList" routerLinkActive="active">KNX</div> <div class="item itemRight" routerLink="GroupList" routerLinkActive="active">KNX</div>
</div> </div>
@ -11,7 +12,7 @@
</div> </div>
<div id="notConnected" *ngIf="apiService.websocketError"> <div id="notConnected" *ngIf="apiService.websocketError">
<div> <div class="text">
Nicht verbunden Nicht verbunden
</div> </div>
</div> </div>

View File

@ -34,7 +34,7 @@
border: @space solid red; border: @space solid red;
color: red; color: red;
div { .text {
text-align: center; text-align: center;
margin: auto; margin: auto;
font-size: 200%; font-size: 200%;

View File

@ -3,11 +3,13 @@ import {KnxGroupListPageComponent} from './pages/knx-group-list-page/knx-group-l
import {DeviceListPageComponent} from './pages/device-list-page/device-list-page.component'; import {DeviceListPageComponent} from './pages/device-list-page/device-list-page.component';
import {ShutterListPageComponent} from './pages/shutter-list-page/shutter-list-page.component'; import {ShutterListPageComponent} from './pages/shutter-list-page/shutter-list-page.component';
import {TunableListPageComponent} from './pages/tunable-list-page/tunable-list-page.component'; import {TunableListPageComponent} from './pages/tunable-list-page/tunable-list-page.component';
import {DashboardComponent} from './pages/dashboard/dashboard.component';
export const routes: Routes = [ export const routes: Routes = [
{path: 'Dashboard', component: DashboardComponent},
{path: 'DeviceList', component: DeviceListPageComponent}, {path: 'DeviceList', component: DeviceListPageComponent},
{path: 'TunableList', component: TunableListPageComponent}, {path: 'TunableList', component: TunableListPageComponent},
{path: 'ShutterList', component: ShutterListPageComponent}, {path: 'ShutterList', component: ShutterListPageComponent},
{path: 'GroupList', component: KnxGroupListPageComponent}, {path: 'GroupList', component: KnxGroupListPageComponent},
{path: '**', redirectTo: 'GroupList'}, {path: '**', redirectTo: 'Dashboard'},
]; ];

View File

@ -0,0 +1,21 @@
<div class="verticalScroll">
<div class="subheading">
Eingeschaltete Geräte:
{{ deviceList.filtered.length }}
</div>
<app-device-list [list]="deviceList.filtered"></app-device-list>
<div class="subheading">
Eingeschaltete Lichter:
{{ tunableList.filtered.length }}
</div>
<app-tunable-list [list]="tunableList.filtered"></app-tunable-list>
<div class="subheading">
{{ shutterSubheading }} Rollläden:
{{ shutterList.filtered.length }}
</div>
<app-shutter-list [list]="shutterList.filtered"></app-shutter-list>
</div>

View File

@ -0,0 +1,7 @@
@import "../../../config";
.subheading {
font-size: 65%;
font-style: italic;
color: gray;
}

View File

@ -0,0 +1,76 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {DeviceService} from '../../api/Device/device.service';
import {TunableService} from '../../api/Tunable/tunable.service';
import {Device} from '../../api/Device/Device';
import {Tunable} from '../../api/Tunable/Tunable';
import {Shutter} from '../../api/Shutter/Shutter';
import {ShutterService} from '../../api/Shutter/shutter.service';
import {DeviceListComponent} from '../../shared/device-list/device-list.component';
import {FormsModule} from '@angular/forms';
import {CrudLiveList} from '../../api/common/CrudLiveList';
import {TunableListComponent} from '../../shared/tunable-list/tunable-list.component';
import {ShutterListComponent} from '../../shared/shutter-list/shutter-list.component';
import {Subscription, timer} from 'rxjs';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
DeviceListComponent,
FormsModule,
TunableListComponent,
ShutterListComponent,
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.less'
})
export class DashboardComponent implements OnInit, OnDestroy {
protected deviceList!: CrudLiveList<Device>;
protected tunableList!: CrudLiveList<Tunable>;
protected shutterList!: CrudLiveList<Shutter>;
protected now: Date = new Date();
private subs: Subscription[] = [];
protected shutterSubheading: string = "";
protected shuttersShouldBeOpen: boolean = false;
constructor(
protected readonly deviceService: DeviceService,
protected readonly tunableService: TunableService,
protected readonly shutterService: ShutterService,
) {
}
ngOnInit(): void {
this.newDate();
this.subs.push(timer(5000, 5000).subscribe(() => this.newDate()));
this.subs.push(this.deviceList = new CrudLiveList(this.deviceService, Device.equals, device => device.stateProperty?.state?.value === true));
this.subs.push(this.tunableList = new CrudLiveList(this.tunableService, Tunable.equals, tunable => tunable.stateProperty?.state?.value === true));
this.subs.push(this.shutterList = new CrudLiveList(this.shutterService, Shutter.equals, shutter => this.shutterFilter(shutter)));
}
private newDate() {
this.now = new Date();
this.shuttersShouldBeOpen = this.now.getHours() >= 7 && this.now.getHours() < 16;
this.shutterSubheading = this.shuttersShouldBeOpen ? "Geschlossene" : "Offene";
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
private shutterFilter(shutter: Shutter) {
if (this.shuttersShouldBeOpen) {
return shutter.positionProperty?.state?.value !== 0;
} else {
return shutter.positionProperty?.state?.value !== 100;
}
}
}

View File

@ -2,7 +2,7 @@
<div class="flexBoxFixed"> <div class="flexBoxFixed">
<input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen..."> <input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen...">
</div> </div>
<div class="flexBoxRest"> <div class="flexBoxRest verticalScroll">
<app-device-list [deviceList]="deviceList"></app-device-list> <app-device-list [list]="deviceList"></app-device-list>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<div class="flexBoxFixed"> <div class="flexBoxFixed">
<input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen..."> <input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen...">
</div> </div>
<div class="flexBoxRest"> <div class="flexBoxRest verticalScroll">
<app-knx-group-list [groupList]="groupList"></app-knx-group-list> <app-knx-group-list [groupList]="groupList"></app-knx-group-list>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<div class="flexBoxFixed"> <div class="flexBoxFixed">
<input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen..."> <input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen...">
</div> </div>
<div class="flexBoxRest"> <div class="flexBoxRest verticalScroll">
<app-shutter-list [shutterList]="shutterList"></app-shutter-list> <app-shutter-list [list]="shutterList"></app-shutter-list>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<div class="flexBoxFixed"> <div class="flexBoxFixed">
<input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen..."> <input type="text" [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen...">
</div> </div>
<div class="flexBoxRest"> <div class="flexBoxRest verticalScroll">
<app-tunable-list [tunableList]="tunableList"></app-tunable-list> <app-tunable-list [list]="tunableList"></app-tunable-list>
</div> </div>
</div> </div>

View File

@ -0,0 +1,9 @@
<div class="all" (click)="toggle()">
<div class="box">
<div class="TRUE" *ngIf="model">{{ labelTrue }}</div>
<div class="FALSE" *ngIf="!model">{{ labelFalse }}</div>
</div>
<div class="label" *ngIf="label">
{{ label }}
</div>
</div>

View File

@ -0,0 +1,15 @@
@import '../../../config';
.all {
white-space: nowrap;
.box {
float: left;
width: 1.5em;
aspect-ratio: 1;
margin-right: @space;
text-align: center;
border: @border solid gray;
}
}

View File

@ -0,0 +1,35 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {NgIf} from '@angular/common';
@Component({
selector: 'app-checkbox',
standalone: true,
imports: [
NgIf
],
templateUrl: './checkbox.component.html',
styleUrl: './checkbox.component.less'
})
export class CheckboxComponent {
@Input()
label: string = '';
@Input()
labelFalse: string = '';
@Input()
labelTrue: string = 'X';
@Input()
model!: boolean;
@Output()
modelChange: EventEmitter<boolean | null> = new EventEmitter();
toggle() {
this.model = !this.model;
this.modelChange.emit(this.model);
}
}

View File

@ -1,6 +1,6 @@
<div class="deviceList tileContainer"> <div class="deviceList tileContainer">
<div class="tile" *ngFor="let device of deviceList; trackBy: Device.trackBy"> <div class="tile" *ngFor="let device of list; trackBy: Device.trackBy">
<div class="device tileInner" [ngClass]="ngClass(device)"> <div class="device tileInner" [ngClass]="ngClass(device)">
@ -9,8 +9,8 @@
</div> </div>
<div class="actions"> <div class="actions">
<div class="switchOn" (click)="deviceService.setState(device, true)"></div> <div class="action switchOn" (click)="deviceService.setState(device, true)"></div>
<div class="switchOff" (click)="deviceService.setState(device, false)"></div> <div class="action switchOff" (click)="deviceService.setState(device, false)"></div>
</div> </div>
<div class="timestamp details"> <div class="timestamp details">

View File

@ -1,8 +1,6 @@
@import "../../../config"; @import "../../../config";
.deviceList { .deviceList {
overflow-y: auto;
height: 100%;
.device { .device {
@ -19,7 +17,7 @@
.actions { .actions {
float: right; float: right;
div { .action {
float: left; float: left;
margin-left: @space; margin-left: @space;
width: 4em; width: 4em;

View File

@ -25,7 +25,7 @@ export class DeviceListComponent implements OnInit, OnDestroy {
protected now: Date = new Date(); protected now: Date = new Date();
@Input() @Input()
deviceList: Device[] = []; list: Device[] = [];
constructor( constructor(
protected readonly deviceService: DeviceService, protected readonly deviceService: DeviceService,

View File

@ -1,8 +1,6 @@
@import "../../../config"; @import "../../../config";
.groupList { .groupList {
overflow-y: auto;
height: 100%;
.group { .group {

View File

@ -1,6 +1,6 @@
<div class="shutterList tileContainer"> <div class="shutterList tileContainer">
<div class="tile" *ngFor="let shutter of shutterList; trackBy: Shutter.trackBy"> <div class="tile" *ngFor="let shutter of list; trackBy: Shutter.trackBy">
<div class="shutter tileInner"> <div class="shutter tileInner">

View File

@ -1,8 +1,6 @@
@import "../../../config"; @import "../../../config";
.shutterList { .shutterList {
overflow-y: auto;
height: 100%;
.shutter { .shutter {
@ -26,7 +24,7 @@
clear: right; clear: right;
float: right; float: right;
div { .action {
float: left; float: left;
margin-left: @space; margin-left: @space;
width: 3em; width: 3em;

View File

@ -26,7 +26,7 @@ export class ShutterListComponent implements OnInit, OnDestroy {
protected now: Date = new Date(); protected now: Date = new Date();
@Input() @Input()
shutterList: Shutter[] = []; list: Shutter[] = [];
constructor( constructor(
protected readonly shutterService: ShutterService, protected readonly shutterService: ShutterService,

View File

@ -0,0 +1,10 @@
<div class="all" (click)="toggle()">
<div class="box">
<div class="FALSE" *ngIf="model === false">{{labelFalse}}</div>
<div class="TRUE" *ngIf="model === true">{{labelTrue}}</div>
<div class="NULL" *ngIf="model === null">{{labelNull}}</div>
</div>
<div class="label" *ngIf="label">
{{ label }}
</div>
</div>

View File

@ -0,0 +1,15 @@
@import '../../../config';
.all {
white-space: nowrap;
.box {
float: left;
width: 1.5em;
aspect-ratio: 1;
margin-right: @space;
text-align: center;
border: @border solid gray;
}
}

View File

@ -0,0 +1,44 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {NgIf} from '@angular/common';
@Component({
selector: 'app-tristate',
standalone: true,
imports: [
NgIf
],
templateUrl: './tristate.component.html',
styleUrl: './tristate.component.less'
})
export class TristateComponent {
@Input()
label: string = '';
@Input()
labelFalse: string = 'J';
@Input()
labelTrue: string = 'N';
@Input()
labelNull: string = '';
@Input()
model: boolean | null = null;
@Output()
modelChange: EventEmitter<boolean | null> = new EventEmitter();
toggle() {
if (this.model === true) {
this.model = false;
} else if (this.model === false) {
this.model = null;
} else {
this.model = true;
}
this.modelChange.emit(this.model);
}
}

View File

@ -1,6 +1,6 @@
<div class="tunableList tileContainer"> <div class="tunableList tileContainer">
<div *ngFor="let tunable of tunableList; trackBy: Tunable.trackBy" class="tile"> <div *ngFor="let tunable of list; trackBy: Tunable.trackBy" class="tile">
<div [ngClass]="ngClass(tunable)" class="tunable tileInner"> <div [ngClass]="ngClass(tunable)" class="tunable tileInner">

View File

@ -1,8 +1,6 @@
@import "../../../config"; @import "../../../config";
.tunableList { .tunableList {
overflow-y: auto;
height: 100%;
.tunable { .tunable {

View File

@ -21,7 +21,7 @@ import {FormsModule} from '@angular/forms';
export class TunableListComponent implements OnInit, OnDestroy { export class TunableListComponent implements OnInit, OnDestroy {
@Input() @Input()
tunableList: Tunable[] = []; list: Tunable[] = [];
protected readonly Tunable = Tunable; protected readonly Tunable = Tunable;

View File

@ -61,6 +61,11 @@
} }
.verticalScroll {
height: 100%;
overflow-y: auto;
}
@media (min-width: 1000px) { @media (min-width: 1000px) {
.tileContainer { .tileContainer {

View File

@ -3,7 +3,6 @@ package de.ph87.home.device;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import de.ph87.home.common.crud.AbstractSearchFilter; import de.ph87.home.common.crud.AbstractSearchFilter;
import de.ph87.home.property.PropertyTypeMismatch; import de.ph87.home.property.PropertyTypeMismatch;
import jakarta.annotation.Nullable;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString; import lombok.ToString;
@ -12,27 +11,24 @@ import lombok.ToString;
@ToString @ToString
public class DeviceFilter extends AbstractSearchFilter { public class DeviceFilter extends AbstractSearchFilter {
@Nullable
@JsonProperty @JsonProperty
private Boolean stateNull; private boolean stateNull;
@Nullable
@JsonProperty @JsonProperty
private Boolean stateTrue; private boolean stateTrue;
@Nullable
@JsonProperty @JsonProperty
private Boolean stateFalse; private boolean stateFalse;
public boolean filter(@NonNull final DeviceDto dto) throws PropertyTypeMismatch { public boolean filter(@NonNull final DeviceDto dto) throws PropertyTypeMismatch {
if (stateNull != null && stateNull != (dto.getStateProperty() == null)) {
return false;
}
final Boolean value = dto.getStateValue(); final Boolean value = dto.getStateValue();
if (stateTrue != null && (value == null || stateTrue != value)) { if (!stateNull && value == null) {
return false; return false;
} }
if (stateFalse != null && (value == null || stateFalse == value)) { if (!stateTrue && Boolean.TRUE.equals(value)) {
return false;
}
if (!stateFalse == Boolean.FALSE.equals(value)) {
return false; return false;
} }
return search(dto.getName()); return search(dto.getName());

View File

@ -3,7 +3,6 @@ package de.ph87.home.tunable;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import de.ph87.home.common.crud.AbstractSearchFilter; import de.ph87.home.common.crud.AbstractSearchFilter;
import de.ph87.home.property.PropertyTypeMismatch; import de.ph87.home.property.PropertyTypeMismatch;
import jakarta.annotation.Nullable;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString; import lombok.ToString;
@ -12,27 +11,24 @@ import lombok.ToString;
@ToString @ToString
public class TunableFilter extends AbstractSearchFilter { public class TunableFilter extends AbstractSearchFilter {
@Nullable
@JsonProperty @JsonProperty
private Boolean stateNull; private boolean stateNull;
@Nullable
@JsonProperty @JsonProperty
private Boolean stateTrue; private boolean stateTrue;
@Nullable
@JsonProperty @JsonProperty
private Boolean stateFalse; private boolean stateFalse;
public boolean filter(@NonNull final TunableDto dto) throws PropertyTypeMismatch { public boolean filter(@NonNull final TunableDto dto) throws PropertyTypeMismatch {
if (stateNull != null && stateNull != (dto.getStateProperty() == null)) {
return false;
}
final Boolean value = dto.getStateValue(); final Boolean value = dto.getStateValue();
if (stateTrue != null && (value == null || stateTrue != value)) { if (!stateNull && value == null) {
return false; return false;
} }
if (stateFalse != null && (value == null || stateFalse == value)) { if (!stateTrue && Boolean.TRUE.equals(value)) {
return false;
}
if (!stateFalse == Boolean.FALSE.equals(value)) {
return false; return false;
} }
return search(dto.getName()); return search(dto.getName());