feat: update to angular 21

This commit is contained in:
Chrystian Huot 2025-12-21 14:21:02 -05:00
parent c9cc7f0a3c
commit 53a426bea9
72 changed files with 10647 additions and 9722 deletions

View file

@ -16,14 +16,15 @@
"root": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "rdio-scanner", "prefix": "rdio-scanner",
"architect": { "targets": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-devkit/build-angular:browser",
"options": { "options": {
"outputPath": "../server/webapp", "outputPath": "../server/webapp",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "polyfills": [
"polyfills": "src/polyfills.ts", "src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
@ -36,15 +37,16 @@
], ],
"scripts": [], "scripts": [],
"serviceWorker": true, "serviceWorker": true,
"ngswConfigPath": "ngsw-config.json" "ngswConfigPath": "ngsw-config.json",
"main": "src/main.ts"
}, },
"configurations": { "configurations": {
"production": { "production": {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "1500kb", "maximumWarning": "2mb",
"maximumError": "1500kb" "maximumError": "2mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
@ -61,9 +63,7 @@
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "development": {
"buildOptimizer": false,
"optimization": false, "optimization": false,
"vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true, "sourceMap": true,
"namedChunks": true "namedChunks": true
@ -75,11 +75,11 @@
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"browserTarget": "rdio-scanner:build:production" "buildTarget": "rdio-scanner:build:production"
}, },
"development": { "development": {
"browserTarget": "rdio-scanner:build:development", "proxyConfig": "src/proxy.conf.js",
"proxyConfig": "src/proxy.conf.js" "buildTarget": "rdio-scanner:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
@ -87,14 +87,16 @@
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n", "builder": "@angular-devkit/build-angular:extract-i18n",
"options": { "options": {
"browserTarget": "rdio-scanner:build" "buildTarget": "rdio-scanner:build"
} }
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"main": "src/test.ts", "main": "src/test.ts",
"polyfills": "src/polyfills.ts", "polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js", "karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
@ -114,5 +116,31 @@
}, },
"cli": { "cli": {
"analytics": false "analytics": false
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
} }
} }

15368
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,34 @@
{ {
"author": "Chrystian Huot <chrystian.huot@saubeo.solutions>", "author": "Chrystian Huot <chrystian@huot.qc.ca>",
"dependencies": { "dependencies": {
"@angular/animations": "^15.0.4", "@angular/animations": "^21.0.6",
"@angular/cdk": "^15.0.3", "@angular/cdk": "^21.0.5",
"@angular/common": "^15.0.4", "@angular/common": "^21.0.6",
"@angular/compiler": "^15.0.4", "@angular/compiler": "^21.0.6",
"@angular/core": "^15.0.4", "@angular/core": "^21.0.6",
"@angular/forms": "^15.0.4", "@angular/forms": "^21.0.6",
"@angular/material": "^15.0.3", "@angular/material": "^21.0.5",
"@angular/platform-browser": "^15.0.4", "@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "^15.0.4", "@angular/platform-browser-dynamic": "^21.0.6",
"@angular/router": "^15.0.4", "@angular/router": "^21.0.6",
"@angular/service-worker": "^15.0.4", "@angular/service-worker": "^21.0.6",
"rxjs": "~7.5.0", "material-icons": "^1.13.14",
"tslib": "^2.3.0", "rxjs": "~7.8.2",
"zone.js": "~0.11.4" "tslib": "^2.8.1",
"zone.js": "~0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^15.0.4", "@angular-devkit/build-angular": "^21.0.4",
"@angular/cli": "^15.0.4", "@angular/build": "^21.0.4",
"@angular/compiler-cli": "^15.0.4", "@angular/cli": "^21.0.4",
"@types/node": "^18.11.2", "@angular/compiler-cli": "^21.0.6",
"@typescript-eslint/eslint-plugin": "^5.40.1", "@types/node": "^25.0.3",
"@typescript-eslint/parser": "^5.40.1", "@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"ajv-keywords": "^5.1.0", "ajv-keywords": "^5.1.0",
"eslint": "^8.25.0", "eslint": "^9.39.2",
"ts-node": "~10.9.1", "ts-node": "~10.9.2",
"typescript": "~4.8.4" "typescript": "~5.9.3"
}, },
"engines": { "engines": {
"node": ">=16.0" "node": ">=16.0"
@ -36,7 +38,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "ng build --base-href ./ --configuration production", "build": "ng build --base-href ./ --configuration production",
"start": "ng serve" "serve": "ng serve"
}, },
"version": "7.0.0" "version": "7.0.0"
} }

View file

@ -23,5 +23,6 @@ import { Component } from '@angular/core';
selector: 'app-root', selector: 'app-root',
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html', templateUrl: './app.component.html',
standalone: false
}) })
export class AppComponent { } export class AppComponent { }

View file

@ -1,43 +1,43 @@
<ng-container *ngIf="!authenticated"> @if (!authenticated) {
<rdio-scanner-admin-login></rdio-scanner-admin-login> <rdio-scanner-admin-login></rdio-scanner-admin-login>
</ng-container> }
<ng-container *ngIf="authenticated"> @if (authenticated) {
<rdio-scanner-admin-todos></rdio-scanner-admin-todos> <rdio-scanner-admin-todos></rdio-scanner-admin-todos>
<mat-accordion displayMode="flat"> <mat-accordion displayMode="flat">
<mat-expansion-panel #configPanel (afterCollapse)="configComponent?.closeAll()"> <mat-expansion-panel #configPanel (afterCollapse)="configComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>settings</mat-icon> <mat-icon>settings</mat-icon>
Config Config
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-config #configComponent></rdio-scanner-admin-config> <rdio-scanner-admin-config #configComponent></rdio-scanner-admin-config>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterExpand)="logsComponent.reload()"> <mat-expansion-panel (afterExpand)="logsComponent.reload()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>article</mat-icon> <mat-icon>article</mat-icon>
Logs Logs
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs> <rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="toolsComponent?.closeAll()"> <mat-expansion-panel (afterCollapse)="toolsComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>build</mat-icon> <mat-icon>build</mat-icon>
Tools Tools
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-tools #toolsComponent (config)="configComponent.reset($event, { dirty: true }); configPanel.open()"></rdio-scanner-admin-tools> <rdio-scanner-admin-tools #toolsComponent (config)="configComponent.reset($event, { dirty: true }); configPanel.open()"></rdio-scanner-admin-tools>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel hideToggle (click)="logout()"> <mat-expansion-panel hideToggle (click)="logout()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>logout</mat-icon> <mat-icon>logout</mat-icon>
Logout Logout
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
</ng-container> }

View file

@ -25,6 +25,7 @@ import { AdminEvent, RdioScannerAdminService, Group, Tag } from './admin.service
selector: 'rdio-scanner-admin', selector: 'rdio-scanner-admin',
styleUrls: ['./admin.component.scss'], styleUrls: ['./admin.component.scss'],
templateUrl: './admin.component.html', templateUrl: './admin.component.html',
standalone: false
}) })
export class RdioScannerAdminComponent implements OnDestroy { export class RdioScannerAdminComponent implements OnDestroy {
authenticated = true; authenticated = true;

View file

@ -17,7 +17,7 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { HttpClientModule } from '@angular/common/http'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { AppSharedModule } from '../../../shared/shared.module'; import { AppSharedModule } from '../../../shared/shared.module';
import { RdioScannerAdminComponent } from './admin.component'; import { RdioScannerAdminComponent } from './admin.component';
@ -45,8 +45,7 @@ import { RdioScannerAdminImportTalkgroupsComponent } from './tools/import-talkgr
import { RdioScannerAdminImportUnitsComponent } from './tools/import-units/import-units.component'; import { RdioScannerAdminImportUnitsComponent } from './tools/import-units/import-units.component';
import { RdioScannerAdminPasswordComponent } from './tools/password/password.component'; import { RdioScannerAdminPasswordComponent } from './tools/password/password.component';
@NgModule({ @NgModule({ declarations: [
declarations: [
RdioScannerAdminComponent, RdioScannerAdminComponent,
RdioScannerAdminConfigComponent, RdioScannerAdminConfigComponent,
RdioScannerAdminAccessComponent, RdioScannerAdminAccessComponent,
@ -71,9 +70,5 @@ import { RdioScannerAdminPasswordComponent } from './tools/password/password.com
RdioScannerAdminToolsComponent, RdioScannerAdminToolsComponent,
RdioScannerAdminUnitComponent, RdioScannerAdminUnitComponent,
], ],
entryComponents: [RdioScannerAdminSystemsSelectComponent], exports: [RdioScannerAdminComponent], imports: [AppSharedModule], providers: [RdioScannerAdminService, provideHttpClient(withInterceptorsFromDi())] })
exports: [RdioScannerAdminComponent],
imports: [AppSharedModule, HttpClientModule],
providers: [RdioScannerAdminService],
})
export class RdioScannerAdminModule { } export class RdioScannerAdminModule { }

View file

@ -1,95 +1,111 @@
<div class="row top"> <div class="row top">
<p class="mat-body">You can decide to leave your instance unlocked and accessible to everyone, or set passwords with <p class="mat-body">You can decide to leave your instance unlocked and accessible to everyone, or set passwords with
specific systems/talkgroups. When used, the user will be prompted once for their password. It is then stored specific systems/talkgroups. When used, the user will be prompted once for their password. It is then stored
internally on the client side and reused thereafter.</p> internally on the client side and reused thereafter.</p>
<button type="button" mat-button color="accent" (click)="add()">New access</button> <button type="button" mat-button color="accent" (click)="add()">New access</button>
</div> </div>
<p *ngIf="!accesses.length" class="mat-small text-center">No access is defined, your instance is open and accessible to @if (!accesses.length) {
all</p> <p class="mat-small text-center">No access is defined, your instance is open and accessible to
all</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)"> <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let access of accesses; index as i" cdkDrag> @for (access of accesses; track access; let i = $index) {
<mat-expansion-panel-header> <mat-expansion-panel cdkDrag>
<mat-panel-title> <mat-expansion-panel-header>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-panel-title>
{{ access.value.ident || 'NewAccess' }} <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="access.invalid" color="warn">error</mat-icon> {{ access.value.ident || 'NewAccess' }}
</mat-panel-title> @if (access.invalid) {
</mat-expansion-panel-header> <mat-icon color="warn">error</mat-icon>
<ng-container [formGroup]="access"> }
<div class="row"> </mat-panel-title>
<p> </mat-expansion-panel-header>
<span class="mat-body">Code</span><br> <ng-container [formGroup]="access">
<span class="mat-caption">The access code to unlock the client.</span> <div class="row">
</p> <p>
<mat-form-field floatLabel="auto"> <span class="mat-body">Code</span><br>
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code"> <span class="mat-caption">The access code to unlock the client.</span>
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode"> </p>
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon> <mat-form-field floatLabel="auto">
</button> <input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
<mat-error *ngIf="access.get('code')?.hasError('duplicate')"> <button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
Code is already defined <mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
</mat-error> </button>
<mat-error *ngIf="access.get('code')?.hasError('required')"> @if (access.get('code')?.hasError('duplicate')) {
Code is required <mat-error>
</mat-error> Code is already defined
</mat-form-field> </mat-error>
</div> }
<div class="row"> @if (access.get('code')?.hasError('required')) {
<p> <mat-error>
<span class="mat-body">Ident</span><br> Code is required
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for </mat-error>
whom.</span> }
</p> </mat-form-field>
<mat-form-field floatLabel="auto"> </div>
<input type="text" matInput formControlName="ident" placeholder="Ident"> <div class="row">
<mat-error *ngIf="access.get('ident')?.hasError('required')"> <p>
Ident is required <span class="mat-body">Ident</span><br>
</mat-error> <span class="mat-caption">Dummy identifier which serves only to identify this access code is for
</mat-form-field> whom.</span>
</div> </p>
<div class="row"> <mat-form-field floatLabel="auto">
<p> <input type="text" matInput formControlName="ident" placeholder="Ident">
<span class="mat-body">Expiration</span><br> @if (access.get('ident')?.hasError('required')) {
<span class="mat-caption">Expiration date for this access code.</span> <mat-error>
</p> Ident is required
<mat-form-field floatLabel="auto"> </mat-error>
<input type="text" matInput formControlName="expiration" placeholder="Expiration date" }
[matDatepicker]="expirationDate" (click)="expirationDate.open()"> </mat-form-field>
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle> </div>
<mat-datepicker #expirationDate></mat-datepicker> <div class="row">
</mat-form-field> <p>
</div> <span class="mat-body">Expiration</span><br>
<div class="row"> <span class="mat-caption">Expiration date for this access code.</span>
<p> </p>
<span class="mat-body">Limit</span><br> <mat-form-field floatLabel="auto">
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection <input type="text" matInput formControlName="expiration" placeholder="Expiration date"
with this access code exceeds the defined limit.</span> [matDatepicker]="expirationDate" (click)="expirationDate.open()">
</p> <mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
<mat-form-field floatLabel="auto"> <mat-datepicker #expirationDate></mat-datepicker>
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit"> </mat-form-field>
</mat-form-field> </div>
</div> <div class="row">
<div class="row"> <p>
<p> <span class="mat-body">Limit</span><br>
<span class="mat-body">Access</span><br> <span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
<span class="mat-caption"> with this access code exceeds the defined limit.</span>
The access code allows access to <u> </p>
<ng-container *ngIf="access.value.systems === '*'">all</ng-container> <mat-form-field floatLabel="auto">
<ng-container *ngIf="access.value.systems !== '*'">some</ng-container> <input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
</u> systems and talkgroups. </mat-form-field>
</span> </div>
</p> <div class="row">
<div> <p>
<button type="button" mat-button [disabled]="access.disabled" (click)="select(access)"> <span class="mat-body">Access</span><br>
Choose systems <span class="mat-caption">
</button> The access code allows access to <u>
</div> @if (access.value.systems === '*') {
</div> all
<div class="row bottom"> }
<button type="button" mat-button color="warn" (click)="remove(i)"> @if (access.value.systems !== '*') {
Delete access some
</button> }
</div> </u> systems and talkgroups.
</ng-container> </span>
</mat-expansion-panel> </p>
<div>
<button type="button" mat-button [disabled]="access.disabled" (click)="select(access)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete access
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion> </mat-accordion>

View file

@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
@Component({ @Component({
selector: 'rdio-scanner-admin-access', selector: 'rdio-scanner-admin-access',
templateUrl: './access.component.html', templateUrl: './access.component.html',
standalone: false
}) })
export class RdioScannerAdminAccessComponent { export class RdioScannerAdminAccessComponent {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;

View file

@ -33,10 +33,10 @@
<button type="button" mat-icon-button matSuffix (click)="copy(key)"> <button type="button" mat-icon-button matSuffix (click)="copy(key)">
<mat-icon>content_copy</mat-icon> <mat-icon>content_copy</mat-icon>
</button> </button>
<mat-error *ngIf="apiKey.get('key')?.hasError('duplicate')"> <mat-error *ngIf="apiKey.get('key').hasError('duplicate')">
Key is already defined Key is already defined
</mat-error> </mat-error>
<mat-error *ngIf="apiKey.get('key')?.hasError('required')"> <mat-error *ngIf="apiKey.get('key').hasError('required')">
Key is required Key is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -49,7 +49,7 @@
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="never">
<input type="text" matInput formControlName="ident" placeholder="Ident"> <input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="apiKey.get('ident')?.hasError('required')"> <mat-error *ngIf="apiKey.get('ident').hasError('required')">
Ident is required Ident is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>

View file

@ -1,80 +1,96 @@
<div class="row top"> <div class="row top">
<p class="mat-body">API keys are used for recorder upload scripts and downstream instances. Each of them must <p class="mat-body">API keys are used for recorder upload scripts and downstream instances. Each of them must
authenticate with an API key to exchange audio files.</p> authenticate with an API key to exchange audio files.</p>
<button type="button" mat-button color="accent" (click)="add()">New API key</button> <button type="button" mat-button color="accent" (click)="add()">New API key</button>
</div> </div>
<p *ngIf="!apikeys?.length" class="mat-small text-center">No defined API keys</p> @if (!apikeys.length) {
<p class="mat-small text-center">No defined API keys</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="apikeys" (cdkDropListDropped)="drop($event)"> <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="apikeys" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let apikey of apikeys; index as i" cdkDrag> @for (apikey of apikeys; track apikey; let i = $index) {
<mat-expansion-panel-header> <mat-expansion-panel cdkDrag>
<mat-panel-title> <mat-expansion-panel-header>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-panel-title>
{{ apikey.value.ident || 'NewApikey' }} <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="apikey.invalid" color="warn">error</mat-icon> {{ apikey.value.ident || 'NewApikey' }}
</mat-panel-title> @if (apikey.invalid) {
</mat-expansion-panel-header> <mat-icon color="warn">error</mat-icon>
<ng-container [formGroup]="apikey"> }
<div class="row"> </mat-panel-title>
<p> </mat-expansion-panel-header>
<span class="mat-body">Disabled</span><br> <ng-container [formGroup]="apikey">
<span class="mat-caption">Disable the API key.</span> <div class="row">
</p> <p>
<div> <span class="mat-body">Disabled</span><br>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle> <span class="mat-caption">Disable the API key.</span>
</div> </p>
</div> <div>
<div class="row"> <mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
<p> </div>
<span class="mat-body">Key</span><br> </div>
<span class="mat-caption">Api key.</span> <div class="row">
</p> <p>
<mat-form-field floatLabel="auto"> <span class="mat-body">Key</span><br>
<input #key type="text" matInput formControlName="key" placeholder="Key"> <span class="mat-caption">Api key.</span>
<button type="button" mat-icon-button matSuffix (click)="copy(key)"> </p>
<mat-icon>content_copy</mat-icon> <mat-form-field floatLabel="auto">
</button> <input #key type="text" matInput formControlName="key" placeholder="Key">
<mat-error *ngIf="apikey.get('key')?.hasError('duplicate')"> <button type="button" mat-icon-button matSuffix (click)="copy(key)">
Key is already defined <mat-icon>content_copy</mat-icon>
</mat-error> </button>
<mat-error *ngIf="apikey.get('key')?.hasError('required')"> @if (apikey.get('key')!.hasError('duplicate')) {
Key is required <mat-error>
</mat-error> Key is already defined
</mat-form-field> </mat-error>
</div> }
<div class="row"> @if (apikey.get('key')!.hasError('required')) {
<p> <mat-error>
<span class="mat-body">Ident</span><br> Key is required
<span class="mat-caption">Dummy identifier which serves only to identify this API key is for </mat-error>
whom.</span> }
</p> </mat-form-field>
<mat-form-field floatLabel="auto"> </div>
<input type="text" matInput formControlName="ident" placeholder="Ident"> <div class="row">
<mat-error *ngIf="apikey.get('ident')?.hasError('required')"> <p>
Ident is required <span class="mat-body">Ident</span><br>
</mat-error> <span class="mat-caption">Dummy identifier which serves only to identify this API key is for
</mat-form-field> whom.</span>
</div> </p>
<div class="row"> <mat-form-field floatLabel="auto">
<p> <input type="text" matInput formControlName="ident" placeholder="Ident">
<span class="mat-body">Access</span><br> @if (apikey.get('ident')!.hasError('required')) {
<span class="mat-caption"> <mat-error>
This API key allows access to <u> Ident is required
<ng-container *ngIf="apikey.value.systems === '*'">all</ng-container> </mat-error>
<ng-container *ngIf="apikey.value.systems !== '*'">some</ng-container> }
</u> systems and talkgroups. </mat-form-field>
</span> </div>
</p> <div class="row">
<div> <p>
<button type="button" mat-button [disabled]="apikey.disabled" (click)="select(apikey)"> <span class="mat-body">Access</span><br>
Choose systems <span class="mat-caption">
</button> This API key allows access to <u>
</div> @if (apikey.value.systems === '*') {
</div> all
<div class="row bottom"> }
<button type="button" mat-button color="warn" (click)="remove(i)"> @if (apikey.value.systems !== '*') {
Delete API key some
</button> }
</div> </u> systems and talkgroups.
</ng-container> </span>
</mat-expansion-panel> </p>
<div>
<button type="button" mat-button [disabled]="apikey.disabled" (click)="select(apikey)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete API key
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion> </mat-accordion>

View file

@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
@Component({ @Component({
selector: 'rdio-scanner-admin-apikeys', selector: 'rdio-scanner-admin-apikeys',
templateUrl: './apikeys.component.html', templateUrl: './apikeys.component.html',
standalone: false
}) })
export class RdioScannerAdminApikeysComponent { export class RdioScannerAdminApikeysComponent {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;

View file

@ -1,90 +1,90 @@
<form *ngIf="form" autocomplete="off" [formGroup]="form" (ngSubmit)="save()"> <form *ngIf="form as f" autocomplete="off" [formGroup]="f" (ngSubmit)="save()">
<mat-accordion displayMode="flat"> <mat-accordion displayMode="flat">
<mat-expansion-panel (afterCollapse)="accessComponent.closeAll()"> <mat-expansion-panel (afterCollapse)="accessComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>manage_accounts</mat-icon> <mat-icon>manage_accounts</mat-icon>
Access Access
<mat-icon *ngIf="form?.get('access')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('access')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access> <rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="apikeyComponent.closeAll()"> <mat-expansion-panel (afterCollapse)="apikeyComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>vpn_key</mat-icon> <mat-icon>vpn_key</mat-icon>
ApiKey ApiKey
<mat-icon *ngIf="form?.get('apikey')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('apikey')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-apikeys #apikeyComponent [form]="apikeys"></rdio-scanner-admin-apikeys> <rdio-scanner-admin-apikeys #apikeyComponent [form]="apikeys"></rdio-scanner-admin-apikeys>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirwatchComponent.closeAll()"> <mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirwatchComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>folder</mat-icon> <mat-icon>folder</mat-icon>
Dirwatch Dirwatch
<mat-icon *ngIf="form?.get('dirwatch')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('dirwatch')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-dirwatch #dirwatchComponent [form]="dirwatch"></rdio-scanner-admin-dirwatch> <rdio-scanner-admin-dirwatch #dirwatchComponent [form]="dirwatch"></rdio-scanner-admin-dirwatch>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()"> <mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>share</mat-icon> <mat-icon>share</mat-icon>
Downstreams Downstreams
<mat-icon *ngIf="form?.get('downstreams')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('downstreams')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams> <rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel> <mat-expansion-panel>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>workspaces</mat-icon> <mat-icon>workspaces</mat-icon>
Groups Groups
<mat-icon *ngIf="form?.get('groups')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('groups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups> <rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel> <mat-expansion-panel>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>tune</mat-icon> <mat-icon>tune</mat-icon>
Options Options
<mat-icon *ngIf="form?.get('options')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('options')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options> <rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="systemsComponent.closeAll()"> <mat-expansion-panel (afterCollapse)="systemsComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>podcasts</mat-icon> <mat-icon>podcasts</mat-icon>
Systems Systems
<mat-icon *ngIf="form?.get('systems')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('systems')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems> <rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel> <mat-expansion-panel>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>sell</mat-icon> <mat-icon>sell</mat-icon>
Tags Tags
<mat-icon *ngIf="form?.get('tags')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('tags')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags> <rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
<div class="row bottom"> <div class="row bottom">
<button type="button" mat-raised-button [disabled]="form.disabled || form.pristine" <button type="button" mat-raised-button [disabled]="f.disabled || f.pristine"
(click)="reset()">Reset</button> (click)="reset()">Reset</button>
<button type="submit" mat-raised-button color="primary" <button type="submit" mat-raised-button color="primary"
[disabled]="form.disabled || form.pristine || !form.valid">Save</button> [disabled]="f.disabled || f.pristine || !f.valid">Save</button>
</div> </div>
</form> </form>

View file

@ -21,6 +21,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
import { FormArray, FormControl, FormGroup } from '@angular/forms'; import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion'; import { MatExpansionPanel } from '@angular/material/expansion';
import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service'; import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -28,6 +30,7 @@ import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
selector: 'rdio-scanner-admin-config', selector: 'rdio-scanner-admin-config',
styleUrls: ['./config.component.scss'], styleUrls: ['./config.component.scss'],
templateUrl: './config.component.html', templateUrl: './config.component.html',
standalone: false
}) })
export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit { export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
docker = false; docker = false;
@ -68,7 +71,7 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
private config: Config | undefined; private config: Config | undefined;
private eventSubscription; private readonly destroy$ = new Subject<void>();
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined; @ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
@ -76,10 +79,9 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
private adminService: RdioScannerAdminService, private adminService: RdioScannerAdminService,
private ngChangeDetectorRef: ChangeDetectorRef, private ngChangeDetectorRef: ChangeDetectorRef,
) { ) {
this.eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => { this.adminService.event.pipe(takeUntil(this.destroy$)).subscribe(async (event: AdminEvent) => {
if ('authenticated' in event && event.authenticated === true) { if ('authenticated' in event && event.authenticated === true) {
this.config = await this.adminService.getConfig(); this.config = await this.adminService.getConfig();
this.reset(); this.reset();
} }
@ -98,7 +100,8 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.eventSubscription.unsubscribe(); this.destroy$.next();
this.destroy$.complete();
} }
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@ -116,12 +119,17 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
reset(config = this.config, options?: { dirty?: boolean }): void { reset(config = this.config, options?: { dirty?: boolean }): void {
this.form = this.adminService.newConfigForm(config); this.form = this.adminService.newConfigForm(config);
this.form.statusChanges.subscribe(() => { this.form.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.ngChangeDetectorRef.markForCheck(); this.ngChangeDetectorRef.markForCheck();
}); });
this.groups.valueChanges.subscribe(() => { const systemsArray = this.systems;
this.systems.controls.forEach((system) => { const groupsArray = this.groups;
const tagsArray = this.tags;
groupsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (!systemsArray) { return; }
systemsArray.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray; const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => { talkgroups.controls.forEach((talkgroup) => {
@ -136,8 +144,9 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
}); });
}); });
this.tags.valueChanges.subscribe(() => { tagsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.systems.controls.forEach((system) => { if (!systemsArray) { return; }
systemsArray.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray; const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => { talkgroups.controls.forEach((talkgroup) => {
@ -160,8 +169,10 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
} }
async save(): Promise<void> { async save(): Promise<void> {
this.form?.markAsPristine(); if (!this.form) { return; }
await this.adminService.saveConfig(this.form?.getRawValue()); this.form.markAsPristine();
await this.adminService.saveConfig(this.form.getRawValue());
} }
} }

View file

@ -1,240 +1,282 @@
<div class="row top"> <div class="row top">
<p class="mat-body">Define a dirwatch to monitor new audio files from any directory.</p> <p class="mat-body">Define a dirwatch to monitor new audio files from any directory.</p>
<button type="button" mat-button color="accent" (click)="add()">New dir watch</button> <button type="button" mat-button color="accent" (click)="add()">New dir watch</button>
</div> </div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="dirwatches" <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="dirwatches"
(cdkDropListDropped)="drop($event)"> (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let dirwatch of dirwatches; index as i" cdkDrag> @for (dirwatch of dirwatches; track dirwatch; let i = $index) {
<mat-expansion-panel-header> <mat-expansion-panel cdkDrag>
<mat-panel-title> <mat-expansion-panel-header>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-panel-title>
{{ dirwatch.value.directory || 'NewDirwatch' }} <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="dirwatch.invalid" color="warn">error</mat-icon> {{ dirwatch.value.directory || 'NewDirwatch' }}
</mat-panel-title> @if (dirwatch.invalid) {
</mat-expansion-panel-header> <mat-icon color="warn">error</mat-icon>
<ng-container [formGroup]="dirwatch"> }
<div class="row"> </mat-panel-title>
<p> </mat-expansion-panel-header>
<span class="mat-body">Disabled</span><br> <ng-container [formGroup]="dirwatch">
<span class="mat-caption">Disable the dirwatch.</span> <div class="row">
</p> <p>
<div> <span class="mat-body">Disabled</span><br>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle> <span class="mat-caption">Disable the dirwatch.</span>
</div> </p>
</div> <div>
<div class="row"> <mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
<p> </div>
<span class="mat-body">Delete After</span><br> </div>
<span class="mat-caption">Delete the audio file after being ingested. If activated, all pre-existing <div class="row">
audio files will be ingested and deleted as soon as the server starts. If not activated, <p>
pre-existing audio files are neither ingested nor deleted.</span> <span class="mat-body">Delete After</span><br>
</p> <span class="mat-caption">Delete the audio file after being ingested. If activated, all pre-existing
<div> audio files will be ingested and deleted as soon as the server starts. If not activated,
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle> pre-existing audio files are neither ingested nor deleted.</span>
</div> </p>
</div> <div>
<div class="row"> <mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
<p> </div>
<span class="mat-body">Type</span><br> </div>
<span class="mat-caption"> <div class="row">
Dirwatch type defines how the metadata are obtained. <p>
<ul> <span class="mat-body">Type</span><br>
<li><b>Default</b> - Extract the metadata from a custom mask.</li> <span class="mat-caption">
<li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li> Dirwatch type defines how the metadata are obtained.
<li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's <ul>
aliases tab.</li> <li><b>Default</b> - Extract the metadata from a custom mask.</li>
<li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li> <li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li>
</ul> <li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's
</span> aliases tab.</li>
</p> <li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li>
<mat-form-field floatLabel="auto"> </ul>
<mat-select formControlName="type" placeholder="Type"> </span>
<mat-option value="default">Default</mat-option> </p>
<mat-option value="dsdplus">DSDPlus Fast Lane</mat-option> <mat-form-field floatLabel="auto">
<mat-option value="sdr-trunk">SDR Trunk</mat-option> <mat-select formControlName="type" placeholder="Type">
<mat-option value="trunk-recorder">Trunk Recorder</mat-option> <mat-option value="default">Default</mat-option>
</mat-select> <mat-option value="dsdplus">DSDPlus Fast Lane</mat-option>
</mat-form-field> <mat-option value="sdr-trunk">SDR Trunk</mat-option>
</div> <mat-option value="trunk-recorder">Trunk Recorder</mat-option>
<div class="row"> </mat-select>
<p> </mat-form-field>
<span class="mat-body">Directory</span><br> </div>
<span class="mat-caption"> <div class="row">
Path of the <p>
<ng-container [ngSwitch]="dirwatch.get('type')?.value"> <span class="mat-body">Directory</span><br>
<ng-container *ngSwitchCase="'dsdplus'"> <span class="mat-caption">
<b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b> Path of the
</ng-container> @switch (dirwatch.get('type')?.value) {
<ng-container *ngSwitchCase="'sdr-trunk'"> @case ('dsdplus') {
<b>Recordings</b> <b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b>
</ng-container> }
<ng-container *ngSwitchCase="'trunk-recorder'"> @case ('sdr-trunk') {
<b>captureDir</b> <b>Recordings</b>
</ng-container> }
<ng-container *ngSwitchDefault> @case ('trunk-recorder') {
local <b>captureDir</b>
</ng-container> }
</ng-container> @default {
directory to monitor for file ingestion. Note that dirwatch is not compatible with networked local
disk. }
</span> }
</p> directory to monitor for file ingestion. Note that dirwatch is not compatible with networked
<mat-form-field floatLabel="auto"> disk.
<input type="text" matInput formControlName="directory" placeholder="Directory"> </span>
<mat-error *ngIf="dirwatch.get('directory')?.hasError('required')"> </p>
Directory is required <mat-form-field floatLabel="auto">
</mat-error> <input type="text" matInput formControlName="directory" placeholder="Directory">
<mat-error *ngIf="dirwatch.get('directory')?.hasError('duplicate')"> @if (dirwatch.get('directory')?.hasError('required')) {
Directory is already defined <mat-error>
</mat-error> Directory is required
<mat-error *ngIf="dirwatch.get('directory')?.hasError('network')"> </mat-error>
Incompatible network folder }
</mat-error> @if (dirwatch.get('directory')?.hasError('duplicate')) {
</mat-form-field> <mat-error>
</div> Directory is already defined
<div class="row" *ngIf="['default','dsdplus','trunk-recorder'].includes(dirwatch.get('type')?.value)"> </mat-error>
<p> }
<span class="mat-body">Extension</span><br> @if (dirwatch.get('directory')?.hasError('network')) {
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3", <mat-error>
"wav".</span> Incompatible network folder
</p> </mat-error>
<mat-form-field floatLabel="auto"> }
<input type="text" matInput formControlName="extension" placeholder="Extension"> </mat-form-field>
<mat-error *ngIf="dirwatch.get('extension')?.hasError('invalid')"> </div>
Invalid extension @if (['default','dsdplus','trunk-recorder'].includes(dirwatch.get('type')?.value)) {
</mat-error> <div class="row">
</mat-form-field> <p>
</div> <span class="mat-body">Extension</span><br>
<div class="row" *ngIf="['default','dsdplus'].includes(dirwatch.get('type')?.value)"> <span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
<p> "wav".</span>
<span class="mat-body">System</span><br> </p>
<span class="mat-caption">System to where the audio files should go.</span> <mat-form-field floatLabel="auto">
</p> <input type="text" matInput formControlName="extension" placeholder="Extension">
<mat-form-field floatLabel="auto"> @if (dirwatch.get('extension')?.hasError('invalid')) {
<mat-select #sysId formControlName="systemId" placeholder="System"> <mat-error>
<mat-option [value]="null"></mat-option> Invalid extension
<ng-container *ngFor="let system of systems"> </mat-error>
<mat-option *ngIf="system.value.id > 0" [value]="system.value.id"> }
{{ system.value.label }} </mat-form-field>
</mat-option> </div>
</ng-container> }
</mat-select> @if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<mat-error *ngIf="dirwatch.get('systemId')?.hasError('required')"> <div class="row">
System is required <p>
</mat-error> <span class="mat-body">System</span><br>
</mat-form-field> <span class="mat-caption">System to where the audio files should go.</span>
</div> </p>
<div class="row" *ngIf="['default','dsdplus'].includes(dirwatch.get('type')?.value)"> <mat-form-field floatLabel="auto">
<p> <mat-select #sysId formControlName="systemId" placeholder="System">
<span class="mat-body">Site</span><br> <mat-option [value]="null"></mat-option>
<span class="mat-caption">Site to where the audio files should go.</span> @for (system of systems; track system) {
</p> @if (system.value.id > 0) {
<mat-form-field floatLabel="auto"> <mat-option [value]="system.value.id">
<mat-select formControlName="siteId" placeholder="Site"> {{ system.value.label }}
<mat-option [value]="null"></mat-option> </mat-option>
<ng-container *ngFor="let site of sites[dirwatch.value.systemId] || []"> }
<mat-option *ngIf="site.value.id > 0" [value]="site.value.id"> }
{{ site.value.label }} </mat-select>
</mat-option> @if (dirwatch.get('systemId')?.hasError('required')) {
</ng-container> <mat-error>
</mat-select> System is required
</mat-form-field> </mat-error>
</div> }
<div class="row" *ngIf="['default','dsdplus'].includes(dirwatch.get('type')?.value)"> </mat-form-field>
<p> </div>
<span class="mat-body">Talkgroup</span><br> }
<span class="mat-caption">Talkgroup to where the audio files should go.</span> @if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
</p> <div class="row">
<mat-form-field floatLabel="auto"> <p>
<mat-select formControlName="talkgroupId" placeholder="Talkgroup"> <span class="mat-body">Site</span><br>
<mat-option [value]="null"></mat-option> <span class="mat-caption">Site to where the audio files should go.</span>
<ng-container *ngFor="let talkgroup of talkgroups[dirwatch.value.systemId] || []"> </p>
<mat-option *ngIf="talkgroup.value.id > 0" [value]="talkgroup.value.id"> <mat-form-field floatLabel="auto">
{{ talkgroup.value.label }} <mat-select formControlName="siteId" placeholder="Site">
</mat-option> <mat-option [value]="null"></mat-option>
</ng-container> @for (site of sites[dirwatch.value.systemId] || []; track site) {
</mat-select> @if (site.value.id > 0) {
<mat-error *ngIf="dirwatch.get('talkgroupId')?.hasError('required')"> <mat-option [value]="site.value.id">
Talkgroup is required {{ site.value.label }}
</mat-error> </mat-option>
</mat-form-field> }
</div> }
<div class="row" *ngIf="['default'].includes(dirwatch.get('type')?.value)"> </mat-select>
<p> </mat-form-field>
<span class="mat-body">Mask</span><br> </div>
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using }
specific META tags. Possible META tags: @if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<ul> <div class="row">
<li><b>#DATE</b> - extract the date like 20201231&nbsp;(YYYYMMMDD), <p>
2020-12-31&nbsp;(YYYY-MM-DD) or 2020_12_31&nbsp;(YYYY_MM_DD). <span class="mat-body">Talkgroup</span><br>
</li> <span class="mat-caption">Talkgroup to where the audio files should go.</span>
<li><b>#GROUP</b> - extract the group label.</li> </p>
<li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li> <mat-form-field floatLabel="auto">
<li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li> <mat-select formControlName="talkgroupId" placeholder="Talkgroup">
<li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li> <mat-option [value]="null"></mat-option>
<li><b>#SITE</b> - extract the site id like 1.</li> @for (talkgroup of talkgroups[dirwatch.value.systemId] || []; track talkgroup) {
<li><b>#SITELBL</b> - extract the site label.</li> @if (talkgroup.value.id > 0) {
<li><b>#SYS</b> - extract the system id like 11.</li> <mat-option [value]="talkgroup.value.id">
<li><b>#SYSLBL</b> - extract the system label.</li> {{ talkgroup.value.label }}
<li><b>#TAG</b> - extract the tag label.</li> </mat-option>
<li><b>#TG</b> - extract the talkgroup id like 1457 in decimal format.</li> }
<li><b>#TGAFS</b> - extract the talkgroup id like 11-061 in AFS (agency-fleet-subfleet) }
format.</li> </mat-select>
<li><b>#TGHZ</b> - extract the frequency in hertz like 119100000 and set the talkgroup id to @if (dirwatch.get('talkgroupId')?.hasError('required')) {
119100.</li> <mat-error>
<li><b>#TGKHZ</b> - extract the frequency in kilohertz like 119100.000 or 119100 and set the Talkgroup is required
talkgroup id to 119100.</li> </mat-error>
<li><b>#TGLBL</b> - extract the talkgroup label.</li> }
<li><b>#TGMHZ</b> - extract the frequency in megahertz like 119.100000 or 119 and set the </mat-form-field>
talkgroup id to 119100.</li> </div>
<li><b>#TIME</b> - extract the local time like 0853439&nbsp;(HHMMSS), }
08-34-39&nbsp;(HH-MM-SS) or 08:34:39&nbsp;(HH:MM:SS).</li> @if (['default'].includes(dirwatch.get('type')?.value)) {
<li><b>#UNIT</b> - extract the unit id like 4424001.</li> <div class="row">
<li><b>#UNITLBL</b> - extract the unit label. Also requires #UNIT.</li> <p>
<li><b>#ZTIME</b> - extract the zulu time like 0453439&nbsp;(HHMMSS), <span class="mat-body">Mask</span><br>
08-34-39&nbsp;(HH-MM-SS) or 04:34:39&nbsp;(HH:MM:SS).</li> <span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
</ul> specific META tags. Possible META tags:
Example: cymx_#TG_#DATE_#TIME_#HZ <ul>
</span> <li><b>#DATE</b> - extract the date like 20201231&nbsp;(YYYYMMMDD),
</p> 2020-12-31&nbsp;(YYYY-MM-DD) or 2020_12_31&nbsp;(YYYY_MM_DD).
<mat-form-field floatLabel="auto"> </li>
<input type="text" matInput formControlName="mask" placeholder="Mask"> <li><b>#GROUP</b> - extract the group label.</li>
<mat-error *ngIf="dirwatch.get('mask')?.hasError('invalid')"> <li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li>
Invalid mask <li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li>
</mat-error> <li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li>
</mat-form-field> <li><b>#SITE</b> - extract the site id like 1.</li>
</div> <li><b>#SITELBL</b> - extract the site label.</li>
<div class="row" *ngIf="['default'].includes(dirwatch.get('type')?.value)"> <li><b>#SYS</b> - extract the system id like 11.</li>
<p> <li><b>#SYSLBL</b> - extract the system label.</li>
<span class="mat-body">Frequency</span><br> <li><b>#TAG</b> - extract the tag label.</li>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span> <li><b>#TG</b> - extract the talkgroup id like 1457 in decimal format.</li>
</p> <li><b>#TGAFS</b> - extract the talkgroup id like 11-061 in AFS (agency-fleet-subfleet)
<mat-form-field floatLabel="auto"> format.</li>
<input type="number" matInput formControlName="frequency" placeholder="Frequency"> <li><b>#TGHZ</b> - extract the frequency in hertz like 119100000 and set the talkgroup id to
<mat-error *ngIf="dirwatch.get('frequency')?.errors"> 119100.</li>
Invalid frequency <li><b>#TGKHZ</b> - extract the frequency in kilohertz like 119100.000 or 119100 and set the
</mat-error> talkgroup id to 119100.</li>
</mat-form-field> <li><b>#TGLBL</b> - extract the talkgroup label.</li>
</div> <li><b>#TGMHZ</b> - extract the frequency in megahertz like 119.100000 or 119 and set the
<div class="row" *ngIf="['default'].includes(dirwatch.get('type')?.value)"> talkgroup id to 119100.</li>
<p> <li><b>#TIME</b> - extract the local time like 0853439&nbsp;(HHMMSS),
<span class="mat-body">Delay</span><br> 08-34-39&nbsp;(HH-MM-SS) or 08:34:39&nbsp;(HH:MM:SS).</li>
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the <li><b>#UNIT</b> - extract the unit id like 4424001.</li>
recorder has created the file. You can set a delay value in milliseconds for the audio file to <li><b>#UNITLBL</b> - extract the unit label. Also requires #UNIT.</li>
settle before ingesting it.</span> <li><b>#ZTIME</b> - extract the zulu time like 0453439&nbsp;(HHMMSS),
</p> 08-34-39&nbsp;(HH-MM-SS) or 04:34:39&nbsp;(HH:MM:SS).</li>
<mat-form-field floatLabel="auto"> </ul>
<input type="number" matInput formControlName="delay" min="2000" placeholder="Delay"> Example: cymx_#TG_#DATE_#TIME_#HZ
<mat-error *ngIf="dirwatch.get('delay')?.hasError('min')"> </span>
Delay value cannot be less than 2000 milliseconds. </p>
</mat-error> <mat-form-field floatLabel="auto">
</mat-form-field> <input type="text" matInput formControlName="mask" placeholder="Mask">
</div> @if (dirwatch.get('mask')?.hasError('invalid')) {
<div class="row bottom"> <mat-error>
<button type="button" mat-button color="warn" (click)="remove(i)"> Invalid mask
Delete dirwatch </mat-error>
</button> }
</div> </mat-form-field>
</ng-container> </div>
</mat-expansion-panel> }
@if (['default'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" matInput formControlName="frequency" placeholder="Frequency">
@if (dirwatch.get('frequency')?.errors) {
<mat-error>
Invalid frequency
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the
recorder has created the file. You can set a delay value in milliseconds for the audio file to
settle before ingesting it.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" matInput formControlName="delay" min="2000" placeholder="Delay">
@if (dirwatch.get('delay')?.hasError('min')) {
<mat-error>
Delay value cannot be less than 2000 milliseconds.
</mat-error>
}
</mat-form-field>
</div>
}
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete dirwatch
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion> </mat-accordion>

View file

@ -26,6 +26,7 @@ import { RdioScannerAdminService } from '../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-dirwatch', selector: 'rdio-scanner-admin-dirwatch',
templateUrl: './dirwatch.component.html', templateUrl: './dirwatch.component.html',
standalone: false
}) })
export class RdioScannerAdminDirwatchComponent implements OnChanges { export class RdioScannerAdminDirwatchComponent implements OnChanges {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;

View file

@ -1,81 +1,101 @@
<div class="row top"> <div class="row top">
<p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p> <p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p>
<button type="button" mat-button color="accent" (click)="add()">New downstream</button> <button type="button" mat-button color="accent" (click)="add()">New downstream</button>
</div> </div>
<p *ngIf="!downstreams.length" class="mat-small text-center">No defined downstreams</p> @if (!downstreams.length) {
<p class="mat-small text-center">No defined downstreams</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="downstreams" (cdkDropListDropped)="drop($event)"> <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="downstreams" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let downstream of downstreams; index as i" cdkDrag> @for (downstream of downstreams; track downstream; let i = $index) {
<mat-expansion-panel-header> <mat-expansion-panel cdkDrag>
<mat-panel-title> <mat-expansion-panel-header>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-panel-title>
{{ downstream.value.url || 'NewDownstream' }} <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="downstream.invalid" color="warn">error</mat-icon> {{ downstream.value.url || 'NewDownstream' }}
</mat-panel-title> @if (downstream.invalid) {
</mat-expansion-panel-header> <mat-icon color="warn">error</mat-icon>
<ng-container [formGroup]="downstream"> }
<div class="row"> </mat-panel-title>
<p> </mat-expansion-panel-header>
<span class="mat-body">Disabled</span><br> <ng-container [formGroup]="downstream">
<span class="mat-caption">Disable the downstream.</span> <div class="row">
</p> <p>
<div> <span class="mat-body">Disabled</span><br>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle> <span class="mat-caption">Disable the downstream.</span>
</div> </p>
</div> <div>
<div class="row"> <mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
<p> </div>
<span class="mat-body">API Key</span><br> </div>
<span class="mat-caption">Api key of the remote instance.</span> <div class="row">
</p> <p>
<mat-form-field floatLabel="auto"> <span class="mat-body">API Key</span><br>
<input #key type="text" matInput formControlName="apikey" placeholder="API key"> <span class="mat-caption">Api key of the remote instance.</span>
<mat-error *ngIf="downstream.get('apikey')?.hasError('duplicate')"> </p>
API key is already defined <mat-form-field floatLabel="auto">
</mat-error> <input #key type="text" matInput formControlName="apikey" placeholder="API key">
<mat-error *ngIf="downstream.get('apikey')?.hasError('required')"> @if (downstream.get('apikey')?.hasError('duplicate')) {
API key is required <mat-error>
</mat-error> API key is already defined
</mat-form-field> </mat-error>
</div> }
<div class="row"> @if (downstream.get('apikey')?.hasError('required')) {
<p> <mat-error>
<span class="mat-body">URL</span><br> API key is required
<span class="mat-caption">URL of the remote instance.</span> </mat-error>
</p> }
<mat-form-field floatLabel="auto"> </mat-form-field>
<input type="text" matInput formControlName="url" placeholder="URL"> </div>
<mat-error *ngIf="downstream.get('url')?.hasError('required')"> <div class="row">
URL is required <p>
</mat-error> <span class="mat-body">URL</span><br>
<mat-error *ngIf="downstream.get('url')?.hasError('invalid')"> <span class="mat-caption">URL of the remote instance.</span>
URL is invalid </p>
</mat-error> <mat-form-field floatLabel="auto">
<mat-error *ngIf="downstream.get('url')?.hasError('duplicate')"> <input type="text" matInput formControlName="url" placeholder="URL">
URL is already defined @if (downstream.get('url')?.hasError('required')) {
</mat-error> <mat-error>
</mat-form-field> URL is required
</div> </mat-error>
<div class="row"> }
<p> @if (downstream.get('url')?.hasError('invalid')) {
<span class="mat-body">Access</span><br> <mat-error>
<span class="mat-caption"> URL is invalid
This downstream allows access to <u> </mat-error>
<ng-container *ngIf="downstream.value.systems === '*'">all</ng-container> }
<ng-container *ngIf="downstream.value.systems !== '*'">some</ng-container> @if (downstream.get('url')?.hasError('duplicate')) {
</u> systems and talkgroups. <mat-error>
</span> URL is already defined
</p> </mat-error>
<div> }
<button type="button" mat-button [disabled]="downstream.disabled" (click)="select(downstream)"> </mat-form-field>
Choose systems </div>
</button> <div class="row">
</div> <p>
</div> <span class="mat-body">Access</span><br>
<div class="row bottom"> <span class="mat-caption">
<button type="button" mat-button color="warn" (click)="remove(i)"> This downstream allows access to <u>
Delete downstream @if (downstream.value.systems === '*') {
</button> all
</div> }
</ng-container> @if (downstream.value.systems !== '*') {
</mat-expansion-panel> some
}
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="downstream.disabled" (click)="select(downstream)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete downstream
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion> </mat-accordion>

View file

@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
@Component({ @Component({
selector: 'rdio-scanner-admin-downstreams', selector: 'rdio-scanner-admin-downstreams',
templateUrl: './downstreams.component.html', templateUrl: './downstreams.component.html',
standalone: false
}) })
export class RdioScannerAdminDownstreamsComponent { export class RdioScannerAdminDownstreamsComponent {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;

View file

@ -1,68 +1,68 @@
<div class="row top"> <div class="row top">
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between <p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
active talkgroups.</p> active talkgroups.</p>
<button type="button" mat-button color="accent" (click)="add()">New Group</button> <button type="button" mat-button color="accent" (click)="add()">New Group</button>
</div> </div>
<p *ngIf="!groups?.length" class="mat-small text-center">No defined Groups</p> <p *ngIf="!groups.length" class="mat-small text-center">No defined Groups</p>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="groups" <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="groups"
(cdkDropListDropped)="drop($event)"> (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let group of groups; index as i" cdkDrag> <mat-expansion-panel *ngFor="let group of groups; let i = index" cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ group.value.label || 'NewGroup' }} {{ group.value.label || 'NewGroup' }}
<mat-icon *ngIf="group.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="group.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<ng-container [formGroup]="group"> <ng-container [formGroup]="group">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Label</span><br> <span class="mat-body">Label</span><br>
<span class="mat-caption">Group label displayed on the main screen, on the search panel and on the <span class="mat-caption">Group label displayed on the main screen, on the search panel and on the
select panel.</span> select panel.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label"> <input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')"> <mat-error *ngIf="group.get('label')!.hasError('required')">
Label is required Label is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Audio alert</span><br> <span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Prefixes an alert sound to all calls assigned to this group. The alert <span class="mat-caption">Prefixes an alert sound to all calls assigned to this group. The alert
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)"> <mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option> <mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert"> <mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }} {{ alert | titlecase }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Led Color</span><br> <span class="mat-body">Led Color</span><br>
<span class="mat-caption">The default LED color is green but it can be changed at several levels. <span class="mat-caption">The default LED color is green but it can be changed at several levels.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color"> <mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option> <mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led"> <mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }} {{ led | titlecase }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row bottom"> <div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)"> <button type="button" mat-button color="warn" (click)="remove(i)">
Delete Group Delete Group
</button> </button>
</div> </div>
</ng-container> </ng-container>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>

View file

@ -18,35 +18,60 @@
*/ */
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core'; import { Component, Input, QueryList, ViewChildren, OnDestroy } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { FormArray, FormGroup } from '@angular/forms'; import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion'; import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select'; import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService } from '../../admin.service'; import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'rdio-scanner-admin-groups', selector: 'rdio-scanner-admin-groups',
templateUrl: './groups.component.html', templateUrl: './groups.component.html',
standalone: false
}) })
export class RdioScannerAdminGroupsComponent { export class RdioScannerAdminGroupsComponent implements OnDestroy {
@Input() form: FormArray | undefined; private _form: FormArray | undefined;
private formSub: Subscription | undefined;
private cachedGroups: FormGroup[] | undefined;
private cacheValid = false;
readonly leds: string[] = [];
readonly alertsArray: string[] = [];
leds: string[]; @Input()
set form(value: FormArray | undefined) {
this._form = value;
this.cacheValid = false;
this.cachedGroups = undefined;
this.formSub?.unsubscribe();
if (this._form) {
this.formSub = this._form.valueChanges.subscribe(() => {
this.cacheValid = false;
});
}
}
get form(): FormArray | undefined {
return this._form;
}
get alerts(): string[] { get alerts(): string[] {
return Object.keys(this.adminService.Alerts || {}); return this.alertsArray;
} }
get groups(): FormGroup[] { get groups(): FormGroup[] {
return this.form?.controls if (this.cacheValid && this.cachedGroups) return this.cachedGroups;
.sort((a, b) => a.value.order - b.value.order) as FormGroup[]; const controls = (this._form?.controls?.slice() || []) as FormGroup[];
this.cachedGroups = controls.sort((a, b) => (a.value.order || 0) - (b.value.order || 0));
this.cacheValid = true;
return this.cachedGroups;
} }
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined; @ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) { constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) {
this.leds = this.adminService.getLeds(); this.leds = this.adminService.getLeds();
this.alertsArray = Object.keys(this.adminService.Alerts || {});
} }
add(): void { add(): void {
@ -57,6 +82,7 @@ export class RdioScannerAdminGroupsComponent {
this.form?.insert(0, group); this.form?.insert(0, group);
this.form?.markAsDirty(); this.form?.markAsDirty();
this.cacheValid = false;
} }
closeAll(): void { closeAll(): void {
@ -70,6 +96,7 @@ export class RdioScannerAdminGroupsComponent {
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false })); event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty(); this.form?.markAsDirty();
this.cacheValid = false;
} }
} }
@ -81,5 +108,10 @@ export class RdioScannerAdminGroupsComponent {
this.form?.removeAt(index); this.form?.removeAt(index);
this.form?.markAsDirty(); this.form?.markAsDirty();
this.cacheValid = false;
}
ngOnDestroy(): void {
this.formSub?.unsubscribe();
} }
} }

View file

@ -1,166 +1,168 @@
<ng-container *ngIf="form" [formGroup]="form"> <ng-container *ngIf="form">
<ng-container [formGroup]="form">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">12-Hour Time Format</span><br> <span class="mat-body">12-Hour Time Format</span><br>
<span class="mat-caption">Display time stamp in 12-hour format.</span> <span class="mat-caption">Display time stamp in 12-hour format.</span>
</p> </p>
<div> <div>
<mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Audio Conversion</span><br> <span class="mat-body">Audio Conversion</span><br>
<span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span> <span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="audioConversion" placeholder="Audio Conversion"> <mat-select formControlName="audioConversion" placeholder="Audio Conversion">
<mat-option [value]="0">Disabled</mat-option> <mat-option [value]="0">Disabled</mat-option>
<mat-option [value]="1">Enabled without normalization</mat-option> <mat-option [value]="1">Enabled without normalization</mat-option>
<mat-option [value]="2">Enabled with normalization</mat-option> <mat-option [value]="2">Enabled with normalization</mat-option>
<mat-option [value]="3">Enabled with loud normalization</mat-option> <mat-option [value]="3">Enabled with loud normalization</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Auto Populate</span><br> <span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Globaly allows the automatic creation of unconfigured systems and <span class="mat-caption">Globally allows the automatic creation of unconfigured systems and
talkgroups.</span> talkgroups.</span>
</p> </p>
<div> <div>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Branding Label</span><br> <span class="mat-body">Branding Label</span><br>
<span class="mat-caption">A simple line of text displayed above the main screen to help identify the Rdio <span class="mat-caption">A simple line of text displayed above the main screen to help identify the Rdio
Scanner instance.</span> Scanner instance.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="branding" placeholder="Branding"> <input type="text" matInput formControlName="branding" placeholder="Branding">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Dimmer Delay</span><br> <span class="mat-body">Dimmer Delay</span><br>
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when <span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
inactive.</span> inactive.</span>
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay"> <input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('required')"> <mat-error *ngIf="form.get('dimmerDelay')?.hasError('required')">
Dimmer delay is required Dimmer delay is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('min')"> <mat-error *ngIf="form.get('dimmerDelay')?.hasError('min')">
Dimmer delay is invalid Dimmer delay is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Disable Duplicate Call Detection</span><br> <span class="mat-body">Disable Duplicate Call Detection</span><br>
<span class="mat-caption">Disable duplicate audio file detection.</span> <span class="mat-caption">Disable duplicate audio file detection.</span>
</p> </p>
<div> <div>
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Duplicate Call Detection Time Frame</span><br> <span class="mat-body">Duplicate Call Detection Time Frame</span><br>
<span class="mat-caption">New incoming calls will be rejected if a call already exists in the database with <span class="mat-caption">New incoming calls will be rejected if a call already exists in the database with
a start time of +/- this delay in milliseconds.</span> a start time of +/- this delay in milliseconds.</span>
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame"> <input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('required')"> <mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('required')">
Duplicate call time frame is required Duplicate call time frame is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('min')"> <mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('min')">
Duplicate call time frame is invalid Duplicate call time frame is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Email Support</span><br> <span class="mat-body">Email Support</span><br>
<span class="mat-caption">Email address where users can write to to get support.</span> <span class="mat-caption">Email address where users can write to to get support.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="email" placeholder="Email"> <input type="text" matInput formControlName="email" placeholder="Email">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Keypad Beep Style</span><br> <span class="mat-body">Keypad Beep Style</span><br>
<span class="mat-caption">Provide audio feedback when pressing buttons.</span> <span class="mat-caption">Provide audio feedback when pressing buttons.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="keypadBeeps" placeholder="Style"> <mat-select formControlName="keypadBeeps" placeholder="Style">
<mat-option value="uniden">Uniden</mat-option> <mat-option value="uniden">Uniden</mat-option>
<mat-option value="whistler">Whistler</mat-option> <mat-option value="whistler">Whistler</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Max Clients</span><br> <span class="mat-body">Max Clients</span><br>
<span class="mat-caption">Max number of simultaneous clients.</span> <span class="mat-caption">Max number of simultaneous clients.</span>
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="maxClients"> <input type="number" min="0" step="1" matInput formControlName="maxClients">
<mat-error *ngIf="form?.get('maxClients')?.hasError('required')"> <mat-error *ngIf="form.get('maxClients')?.hasError('required')">
Max clients is required Max clients is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('maxClients')?.hasError('min')"> <mat-error *ngIf="form.get('maxClients')?.hasError('min')">
Max clients is invalid Max clients is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Playback Mode Goes Live</span><br> <span class="mat-body">Playback Mode Goes Live</span><br>
<span class="mat-caption">Playback mode changes to live stream mode when the search list is <span class="mat-caption">Playback mode changes to live stream mode when the search list is
exhausted.</span> exhausted.</span>
</p> </p>
<div> <div>
<mat-slide-toggle color="primary" formControlName="playbackGoesLive"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="playbackGoesLive"></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Prune Days</span><br> <span class="mat-body">Prune Days</span><br>
<span class="mat-caption">Prune the database for data older than the specified number of days. Set to 0 to <span class="mat-caption">Prune the database for data older than the specified number of days. Set to 0 to
disable.</span> disable.</span>
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="pruneDays"> <input type="number" min="0" step="1" matInput formControlName="pruneDays">
<mat-error *ngIf="form?.get('pruneDays')?.hasError('required')"> <mat-error *ngIf="form.get('pruneDays')?.hasError('required')">
Prune days is required Prune days is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('pruneDays')?.hasError('min')"> <mat-error *ngIf="form.get('pruneDays')?.hasError('min')">
Prune days is invalid Prune days is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Show Listeners Count</span><br> <span class="mat-body">Show Listeners Count</span><br>
<span class="mat-caption">Show listeners count on main screen.</span> <span class="mat-caption">Show listeners count on main screen.</span>
</p> </p>
<div> <div>
<mat-slide-toggle color="primary" formControlName="showListenersCount"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="showListenersCount"></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Sort Talkgroups</span><br> <span class="mat-body">Sort Talkgroups</span><br>
<span class="mat-caption">Automatically sort talkgroups by their id.</span> <span class="mat-caption">Automatically sort talkgroups by their id.</span>
</p> </p>
<div> <div>
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
</div> </div>
</div> </div>
</ng-container>
</ng-container> </ng-container>

View file

@ -17,13 +17,19 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { Component, Input } from '@angular/core'; import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
@Component({ @Component({
selector: 'rdio-scanner-admin-options', selector: 'rdio-scanner-admin-options',
templateUrl: './options.component.html', templateUrl: './options.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RdioScannerAdminOptionsComponent { export class RdioScannerAdminOptionsComponent {
@Input() form: FormGroup | undefined; @Input() form: FormGroup | null = null;
get controls() {
return this.form ? this.form.controls : {};
}
} }

View file

@ -1,50 +1,57 @@
<div> <div>
<div mat-dialog-title>Systems and talkgroups selection</div> <div mat-dialog-title>Systems and talkgroups selection</div>
<div [formGroup]="select" mat-dialog-content> <div [formGroup]="select" mat-dialog-content>
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all" <mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.everything"> [indeterminate]="indeterminate.everything">
Everything Everything
</mat-checkbox> </mat-checkbox>
<h3 class="mat-caption">Groups</h3> <h3 class="mat-caption">Groups</h3>
<div class="flex-row wrap" formArrayName="groups"> <div class="flex-row wrap" formArrayName="groups">
<div *ngFor="let group of configGroups; index as groupIndex" [formGroupName]="groupIndex"> <ng-container *ngFor="let group of configGroups; let groupIndex = index">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked" <div [formGroupName]="groupIndex">
[indeterminate]="indeterminate.groups[groupIndex]"> <mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
{{ group.value.label }} [indeterminate]="indeterminate.groups[groupIndex]">
</mat-checkbox> {{ group.value.label }}
</div> </mat-checkbox>
</div>
<h3 class="mat-caption">Tags</h3>
<div class="flex-row wrap" formArrayName="tags">
<div *ngFor="let tag of configTags; index as tagIndex" [formGroupName]="tagIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.tags[tagIndex]">
{{ tag.value.label }}
</mat-checkbox>
</div>
</div>
<h3 class="mat-caption">Systems</h3>
<div formArrayName="systems">
<div *ngFor="let system of configSystems; index as systemIndex" [formGroupName]="systemIndex">
<div class="flex-row margin">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.systems[systemIndex]">
{{ system.value.label }}
</mat-checkbox>
</div>
<div class="flex-row wrap indent margin" formArrayName="talkgroups">
<div *ngFor="let talkgroup of configTalkgroups[systemIndex]; index as talkgroupIndex"
[formGroupName]="talkgroupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
{{ talkgroup.value.label }}
</mat-checkbox>
</div>
</div>
</div>
</div> </div>
</ng-container>
</div> </div>
<div mat-dialog-actions> <h3 class="mat-caption">Tags</h3>
<button mat-raised-button (click)="cancel()">Cancel</button> <div class="flex-row wrap" formArrayName="tags">
<button mat-raised-button color="primary" (click)="accept()">Ok</button> <ng-container *ngFor="let tag of configTags; let tagIndex = index">
<div [formGroupName]="tagIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.tags[tagIndex]">
{{ tag.value.label }}
</mat-checkbox>
</div>
</ng-container>
</div> </div>
<h3 class="mat-caption">Systems</h3>
<div formArrayName="systems">
<ng-container *ngFor="let system of configSystems; let systemIndex = index">
<div [formGroupName]="systemIndex">
<div class="flex-row margin">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.systems[systemIndex]">
{{ system.value.label }}
</mat-checkbox>
</div>
<div class="flex-row wrap indent margin" formArrayName="talkgroups">
<ng-container *ngFor="let talkgroup of configTalkgroups[systemIndex]; let talkgroupIndex = index">
<div [formGroupName]="talkgroupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
{{ talkgroup.value.label }}
</mat-checkbox>
</div>
</ng-container>
</div>
</div>
</ng-container>
</div>
</div>
<div mat-dialog-actions>
<button mat-raised-button (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
</div>
</div> </div>

View file

@ -17,10 +17,11 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { Component, Inject, ViewEncapsulation } from '@angular/core'; import { Component, Inject, ViewEncapsulation, OnDestroy } from '@angular/core';
import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Access } from '../../../admin.service'; import { Access } from '../../../admin.service';
import { Subscription } from 'rxjs';
interface System { interface System {
all: boolean; all: boolean;
@ -38,8 +39,9 @@ interface Talkgroup {
selector: 'rdio-scanner-admin-systems-selection', selector: 'rdio-scanner-admin-systems-selection',
styleUrls: ['./select.component.scss'], styleUrls: ['./select.component.scss'],
templateUrl: './select.component.html', templateUrl: './select.component.html',
standalone: false
}) })
export class RdioScannerAdminSystemsSelectComponent { export class RdioScannerAdminSystemsSelectComponent implements OnDestroy {
indeterminate = { indeterminate = {
everything: false, everything: false,
groups: [] as boolean[], groups: [] as boolean[],
@ -53,22 +55,21 @@ export class RdioScannerAdminSystemsSelectComponent {
get configGroups(): FormGroup[] { get configGroups(): FormGroup[] {
const faGroups = this.access.root.get('groups') as FormArray; const faGroups = this.access.root.get('groups') as FormArray;
return faGroups.controls as FormGroup[]; return faGroups.controls as FormGroup[];
} }
get configSystems(): FormGroup[] { get configSystems(): FormGroup[] {
const faSystems = this.access.root.get('systems') as FormArray; const faSystems = this.access.root.get('systems') as FormArray;
return faSystems.controls as FormGroup[]; return faSystems.controls as FormGroup[];
} }
get configTags(): FormGroup[] { get configTags(): FormGroup[] {
const faTags = this.access.root.get('tags') as FormArray; const faTags = this.access.root.get('tags') as FormArray;
return faTags.controls as FormGroup[]; return faTags.controls as FormGroup[];
} }
private subs = new Subscription();
constructor( constructor(
@Inject(MAT_DIALOG_DATA) public access: FormGroup, @Inject(MAT_DIALOG_DATA) public access: FormGroup,
private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>, private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>,
@ -76,7 +77,6 @@ export class RdioScannerAdminSystemsSelectComponent {
) { ) {
this.configTalkgroups = this.configSystems.map((fgSystem) => { this.configTalkgroups = this.configSystems.map((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls as FormGroup[]; return faTalkgroups.controls as FormGroup[];
}); });
@ -92,179 +92,140 @@ export class RdioScannerAdminSystemsSelectComponent {
const faSystems = this.select.get('systems') as FormArray; const faSystems = this.select.get('systems') as FormArray;
const faTags = this.select.get('tags') as FormArray; const faTags = this.select.get('tags') as FormArray;
this.configGroups.forEach((configGroup) => { for (const configGroup of this.configGroups) {
const fgGroup = this.ngFormBuilder.group({ const fgGroup = this.ngFormBuilder.group({
id: this.ngFormBuilder.control(configGroup.get('id')?.value), id: this.ngFormBuilder.control(configGroup.get('id')?.value),
checked: this.ngFormBuilder.control(false), checked: this.ngFormBuilder.control(false),
}); });
faGroups.push(fgGroup); faGroups.push(fgGroup);
this.subs.add(fgGroup.valueChanges.subscribe((vGroup) => {
fgGroup.valueChanges.subscribe((vGroup) => { for (const fgSystem of faSystems.controls) {
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => { const ids = fgTalkgroup.get('groupIds')?.value;
if (fgTalkgroup.get('groupIds')?.value.includes(vGroup.id) && fgTalkgroup.get('checked')?.value !== vGroup.checked) { if (ids && ids.includes(vGroup.id) && fgTalkgroup.get('checked')?.value !== vGroup.checked) {
fgTalkgroup.get('checked')?.setValue(vGroup.checked); fgTalkgroup.get('checked')?.setValue(vGroup.checked);
} }
}); }
}); }
}); }));
}); }
this.configSystems.forEach((configSystem, index) => { for (let index = 0; index < this.configSystems.length; index++) {
const configSystem = this.configSystems[index];
const fcSystemAll = this.ngFormBuilder.control(false); const fcSystemAll = this.ngFormBuilder.control(false);
const faSystemTalkgroups = this.ngFormBuilder.array<FormGroup>([]);
const faSystemTalkgroups = this.ngFormBuilder.array<FormGroup<{
checked: FormControl<boolean>;
groupIds: FormControl<number[]>;
id: FormControl<number>;
tagId: FormControl<number>;
}>>([]);
const fgSystem = this.ngFormBuilder.group({ const fgSystem = this.ngFormBuilder.group({
all: fcSystemAll, all: fcSystemAll,
id: this.ngFormBuilder.control(configSystem.get('systemRef')?.value), id: this.ngFormBuilder.control(configSystem.get('systemRef')?.value),
talkgroups: faSystemTalkgroups talkgroups: faSystemTalkgroups
}); });
this.configTalkgroups[index].forEach((configTalkgroup) => { for (const configTalkgroup of this.configTalkgroups[index]) {
const fgSystemTalkgroup = this.ngFormBuilder.group({ const fgSystemTalkgroup = this.ngFormBuilder.group({
checked: this.ngFormBuilder.nonNullable.control(false), checked: this.ngFormBuilder.nonNullable.control(false),
groupIds: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('groupIds')?.value), groupIds: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('groupIds')?.value),
id: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('talkgroupRef')?.value), id: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('talkgroupRef')?.value),
tagId: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('tagId')?.value), tagId: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('tagId')?.value),
}); });
faSystemTalkgroups.push(fgSystemTalkgroup); faSystemTalkgroups.push(fgSystemTalkgroup);
this.subs.add(fgSystemTalkgroup.valueChanges.subscribe(() => {
fgSystemTalkgroup.valueChanges.subscribe(() => { const vAll = faSystemTalkgroups.controls.every((t) => t.get('checked')?.value);
const vAll = faSystemTalkgroups.controls.every((systemTalkgroup) => systemTalkgroup.get('checked')?.value);
fcSystemAll.setValue(vAll, { emitEvent: false }); fcSystemAll.setValue(vAll, { emitEvent: false });
}); }));
}); }
faSystems.push(fgSystem); faSystems.push(fgSystem);
fgSystem.valueChanges.subscribe(() => { this.subs.add(fgSystem.valueChanges.subscribe(() => {
this.rebuildGroupIndeterminates(); this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates(); this.rebuildTagIndeterminates();
}); }));
fcSystemAll.valueChanges.subscribe((vAll) => { this.subs.add(fcSystemAll.valueChanges.subscribe((vAll) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => fgTalkgroup.get('checked')?.setValue(vAll)); fgTalkgroup.get('checked')?.setValue(vAll);
}
this.rebuildGroupIndeterminates(); this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates(); this.rebuildTagIndeterminates();
}); }));
faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => { this.subs.add(faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let off = 0;
let on = 0; let on = 0;
let off = 0;
vSystemTalkgroups.forEach((vSystemTalkgroup) => { for (const v of vSystemTalkgroups) {
if (vSystemTalkgroup.checked) { if (v.checked) on++; else off++;
on++; }
} else {
off++;
}
});
this.indeterminate.systems[index] = !!off && !!on; this.indeterminate.systems[index] = !!off && !!on;
faSystems.at(index).get('all')?.setValue(!off && on, { emitEvent: false }); faSystems.at(index).get('all')?.setValue(!off && on, { emitEvent: false });
}); }));
}); }
this.configTags.forEach((configTag) => { for (const configTag of this.configTags) {
const fgTag = this.ngFormBuilder.group({ const fgTag = this.ngFormBuilder.group({
id: this.ngFormBuilder.control(configTag.value.id), id: this.ngFormBuilder.control(configTag.value.id),
checked: this.ngFormBuilder.control(false), checked: this.ngFormBuilder.control(false),
}); });
faTags.push(fgTag); faTags.push(fgTag);
this.subs.add(fgTag.valueChanges.subscribe((vTag) => {
fgTag.valueChanges.subscribe((vTag) => { for (const fgSystem of faSystems.controls) {
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.tagId === vTag.id && fgTalkgroup.value.checked !== vTag.checked) { if (fgTalkgroup.value.tagId === vTag.id && fgTalkgroup.value.checked !== vTag.checked) {
fgTalkgroup.get('checked')?.setValue(vTag.checked); fgTalkgroup.get('checked')?.setValue(vTag.checked);
} }
}); }
});
});
});
fcAll.valueChanges.subscribe((vAll) => {
faSystems.controls.flatMap((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls;
}).concat(faGroups.controls, faTags.controls).forEach((control) => {
control.get('checked')?.setValue(vAll);
});
});
faSystems.valueChanges.subscribe((vSystems) => {
let off = 0;
let on = 0;
vSystems.forEach((vSystem: System) => {
if (vSystem.all) {
on++;
} else {
off++;
} }
}); }));
}
this.subs.add(fcAll.valueChanges.subscribe((vAll) => {
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
fgTalkgroup.get('checked')?.setValue(vAll);
}
}
for (const fg of faGroups.controls) fg.get('checked')?.setValue(vAll);
for (const fg of faTags.controls) fg.get('checked')?.setValue(vAll);
}));
this.subs.add(faSystems.valueChanges.subscribe((vSystems: System[]) => {
let on = 0;
let off = 0;
for (const vSystem of vSystems) {
if (vSystem.all) on++; else off++;
}
this.indeterminate.everything = !!off && !!on; this.indeterminate.everything = !!off && !!on;
fcAll.setValue(!off && on, { emitEvent: false }); fcAll.setValue(!off && on, { emitEvent: false });
}); }));
const vAccess: Access = this.access.value; const vAccess: Access = this.access.value;
if (vAccess.systems === '*') { if (vAccess.systems === '*') {
this.select.get('all')?.setValue(true); this.select.get('all')?.setValue(true);
} else if (Array.isArray(vAccess.systems)) { } else if (Array.isArray(vAccess.systems)) {
vAccess.systems.forEach((vSystem) => { for (const vSystem of vAccess.systems) {
if (typeof vSystem === 'number') { if (typeof vSystem === 'number') {
faSystems.controls.find((fgSystem) => fgSystem.get('id')?.value === vSystem)?.get('all')?.setValue(true); faSystems.controls.find((fgSystem) => fgSystem.get('id')?.value === vSystem)?.get('all')?.setValue(true);
} else if (vSystem && typeof vSystem === 'object') {
} else if (vSystem !== null && typeof vSystem === 'object') { const fgSystem = faSystems.controls.find((fg) => fg.get('id')?.value === vSystem.id);
const fgSystem = faSystems.controls.find((fg) => {
return fg.get('id')?.value === vSystem.id;
});
if (fgSystem) { if (fgSystem) {
if (vSystem.talkgroups === '*') { if (vSystem.talkgroups === '*') {
fgSystem.get('all')?.setValue(true); fgSystem.get('all')?.setValue(true);
} else if (Array.isArray(vSystem.talkgroups)) { } else if (Array.isArray(vSystem.talkgroups)) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const talkgroup of vSystem.talkgroups) {
vSystem.talkgroups.forEach((talkgroup: { id: number } | number) => {
const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id; const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id;
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.get('id')?.value === talkgroupId); const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.get('id')?.value === talkgroupId);
fgTalkgroup?.get('checked')?.setValue(true); fgTalkgroup?.get('checked')?.setValue(true);
}); }
fgSystem.updateValueAndValidity();
fgSystem?.updateValueAndValidity();
} }
} }
} }
}); }
} }
} }
@ -273,11 +234,7 @@ export class RdioScannerAdminSystemsSelectComponent {
return system['all'] || system['talkgroups'].some((talkgroup: Talkgroup) => talkgroup.checked); return system['all'] || system['talkgroups'].some((talkgroup: Talkgroup) => talkgroup.checked);
}).map((system: System) => { }).map((system: System) => {
if (system['all']) { if (system['all']) {
return { return { id: system['id'], talkgroups: '*' };
id: system['id'],
talkgroups: '*',
};
} else { } else {
return { return {
id: system['id'], id: system['id'],
@ -297,61 +254,47 @@ export class RdioScannerAdminSystemsSelectComponent {
private rebuildGroupIndeterminates(): void { private rebuildGroupIndeterminates(): void {
const faGroups = this.select.get('groups') as FormArray; const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray; const faSystems = this.select.get('systems') as FormArray;
faGroups.controls.forEach((fgGroup, index) => { for (let index = 0; index < faGroups.length; index++) {
let off = 0; const fgGroup = faGroups.at(index);
let on = 0; let on = 0;
let off = 0;
faSystems.controls.forEach((fgSystem) => { for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.get('groupIds')?.value.includes(fgGroup.get('id')?.value)) { if (fgTalkgroup.get('groupIds')?.value.includes(fgGroup.get('id')?.value)) {
if (fgTalkgroup.get('checked')?.value) { if (fgTalkgroup.get('checked')?.value) on++; else off++;
on++;
} else {
off++;
}
} }
}); }
}); }
this.indeterminate.groups[index] = !!off && !!on; this.indeterminate.groups[index] = !!off && !!on;
fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false }); fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false });
}); }
} }
private rebuildTagIndeterminates(): void { private rebuildTagIndeterminates(): void {
const faTags = this.select.get('tags') as FormArray; const faTags = this.select.get('tags') as FormArray;
const faSystems = this.select.get('systems') as FormArray; const faSystems = this.select.get('systems') as FormArray;
faTags.controls.forEach((fgTag, index) => { for (let index = 0; index < faTags.length; index++) {
let off = 0; const fgTag = faTags.at(index);
let on = 0; let on = 0;
let off = 0;
faSystems.controls.forEach((fgSystem) => { for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.tagId === fgTag.value.id) { if (fgTalkgroup.value.tagId === fgTag.value.id) {
if (fgTalkgroup.value.checked) { if (fgTalkgroup.value.checked) on++; else off++;
on++;
} else {
off++;
}
} }
}); }
}); }
this.indeterminate.tags[index] = !!off && !!on; this.indeterminate.tags[index] = !!off && !!on;
fgTag.get('checked')?.setValue(!off && on, { emitEvent: false }); fgTag.get('checked')?.setValue(!off && on, { emitEvent: false });
}); }
}
ngOnDestroy(): void {
this.subs.unsubscribe();
} }
} }

View file

@ -1,37 +1,43 @@
<ng-container *ngIf="form" [formGroup]="form"> <ng-container *ngIf="form as f" [formGroup]="f">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Id</span><br> <span class="mat-body">Id</span><br>
<span class="mat-caption">Site identifier in decimal format.</span> <span class="mat-caption">Site identifier in decimal format.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="siteRef" placeholder="Id"> <ng-container *ngIf="f.get('siteRef') as siteRef">
<mat-error *ngIf="form?.get('siteRef')?.hasError('duplicate')"> <input type="number" min="1" step="1" matInput formControlName="siteRef" placeholder="Id" />
Id is already defined <mat-error *ngIf="siteRef.hasError('duplicate')">
</mat-error> Id is already defined
<mat-error *ngIf="form?.get('siteRef')?.hasError('min')"> </mat-error>
Id is invalid <mat-error *ngIf="siteRef.hasError('min')">
</mat-error> Id is invalid
<mat-error *ngIf="form?.get('siteRef')?.hasError('required')"> </mat-error>
Id is required <mat-error *ngIf="siteRef.hasError('required')">
</mat-error> Id is required
</mat-form-field> </mat-error>
</div> </ng-container>
<div class="row"> </mat-form-field>
<p> </div>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Site label.</span> <div class="row">
</p> <p>
<mat-form-field floatLabel="auto"> <span class="mat-body">Label</span><br>
<input type="text" matInput formControlName="label" placeholder="Label"> <span class="mat-caption">Site label.</span>
<mat-error *ngIf="form?.get('label')?.hasError('required')"> </p>
Label is required <mat-form-field floatLabel="auto">
</mat-error> <ng-container *ngIf="f.get('label') as labelCtrl">
</mat-form-field> <input type="text" matInput formControlName="label" placeholder="Label" />
</div> <mat-error *ngIf="labelCtrl.hasError('required')">
<div class="row bottom"> Label is required
<button type="button" mat-button color="warn" (click)="remove.emit()"> </mat-error>
Delete site </ng-container>
</button> </mat-form-field>
</div> </div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete site
</button>
</div>
</ng-container> </ng-container>

View file

@ -17,15 +17,36 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup, AbstractControl } from '@angular/forms';
@Component({ @Component({
selector: 'rdio-scanner-admin-site', selector: 'rdio-scanner-admin-site',
templateUrl: './site.component.html', templateUrl: './site.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RdioScannerAdminSiteComponent { export class RdioScannerAdminSiteComponent {
@Input() form: FormGroup | undefined; private _form: FormGroup | undefined;
@Input()
set form(f: FormGroup | undefined) {
this._form = f;
this.cd.markForCheck();
}
get form(): FormGroup | undefined {
return this._form;
}
get siteRef(): AbstractControl | null | undefined {
return this._form?.get('siteRef');
}
get labelControl(): AbstractControl | null | undefined {
return this._form?.get('label');
}
@Output() remove = new EventEmitter<void>(); @Output() remove = new EventEmitter<void>();
constructor(private readonly cd: ChangeDetectorRef) {}
} }

View file

@ -1,229 +1,228 @@
<ng-container [formGroup]="form"> <ng-container [formGroup]="form">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Id</span><br> <span class="mat-body">Id</span><br>
<span class="mat-caption">System identifier in decimal format.</span> <span class="mat-caption">System identifier in decimal format.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="systemRef" placeholder="Id"> <input type="number" min="1" step="1" matInput formControlName="systemRef" placeholder="Id">
<mat-error *ngIf="form.get('systemRef')?.hasError('duplicate')"> <mat-error *ngIf="form.get('systemRef')?.hasError('duplicate')">
Id is already defined Id is already defined
</mat-error> </mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('min')"> <mat-error *ngIf="form.get('systemRef')?.hasError('min')">
Id is invalid Id is invalid
</mat-error> </mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('required')"> <mat-error *ngIf="form.get('systemRef')?.hasError('required')">
Id is required Id is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Label</span><br> <span class="mat-body">Label</span><br>
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span> <span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label"> <input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')?.hasError('required')"> <mat-error *ngIf="form.get('label')?.hasError('required')">
Label is required Label is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Audio alert</span><br> <span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Prefixes an alert sound to all calls assigned from this system. The alert <span class="mat-caption">Prefixes an alert sound to all calls assigned from this system. The alert
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)"> <mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option> <mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert"> <mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }} {{ alert | titlecase }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Auto Populate</span><br> <span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span> <span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
</p> </p>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Delay</span><br> <span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span> <span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes"> <input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Led Color</span><br> <span class="mat-body">Led Color</span><br>
<span class="mat-caption">The default LED color is green but it can be changed at several levels. <span class="mat-caption">The default LED color is green but it can be changed at several levels.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color"> <mat-select formControlName="led" placeholder="Color">
<mat-option [value]="''">Default</mat-option> <mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led"> <mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }} {{ led | titlecase }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Blacklists</span><br> <span class="mat-body">Blacklists</span><br>
<span class="mat-caption">A comma separated list of talkgroup Ids to blacklist from this system. This only <span class="mat-caption">A comma separated list of talkgroup Ids to blacklist from this system. This only
has an effect if the auto populate option is enabled.</span> has an effect if the auto populate option is enabled.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<textarea type="text" matInput formControlName="blacklists" placeholder="Blacklists"></textarea> <textarea matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
<mat-error *ngIf="form?.get('blacklists')?.hasError('invalid')"> <mat-error *ngIf="form.get('blacklists')!.hasError('invalid')">
Comma separated list of talkgroup Ids Comma separated list of talkgroup Ids
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Type</span><br> <span class="mat-body">Type</span><br>
<span class="mat-caption"> <span class="mat-caption">
Type of radio system displayed on main screen. Type of radio system displayed on main screen.
<ul> <ul>
<li><b>AM</b> - Amplitude modulation.</li> <li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li> <li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li> <li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li> <li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li> <li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li> <li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li> <li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li> <li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li> <li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul> </ul>
</span> </span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type"> <mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option> <mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option> <mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option> <mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option> <mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option> <mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option> <mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option> <mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option> <mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option> <mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option> <mat-option value="tetra">Tetra</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<mat-accordion displayMode="flat"> <mat-accordion displayMode="flat">
<mat-expansion-panel> <mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Sites
<mat-icon *ngIf="form.get('sites')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">System sites, you can drag and drop sites to rearrange them.</p>
<button type="button" mat-button color="accent" (click)="addSite()">New Site</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="sites"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let site of sites; let i = index; trackBy: trackById" [formGroup]="site" cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
Sites <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="form.get('sites')?.invalid" color="warn">error</mat-icon> {{ site.value.label?.trim() || 'NewSite' }}
</mat-panel-title> <mat-icon *ngIf="site.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<div class="row top"> <rdio-scanner-admin-site [form]="site" (remove)="removeSite(i)"></rdio-scanner-admin-site>
<p class="mat-caption">System sites, you can drag and drop sites to rearrange them.</p>
<button type="button" mat-button color="accent" (click)="addSite()">New Site</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="sites"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let site of sites; index as i" [formGroup]="site" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ site.value.label?.trim() || 'NewSite' }}
<mat-icon *ngIf="site.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-site [form]="site" (remove)="removeSite(i)"></rdio-scanner-admin-site>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</ng-template> </ng-template>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
<mat-accordion displayMode="flat"> </ng-template>
<mat-expansion-panel> </mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Talkgroups
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">System talkgroups, you can drag and drop talkgroups to rearrange them. They
will appear with the same order on the talkgroup selection panel unless the sort talkgroups
option is enabled.</p>
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64"
[cdkDropListData]="talkgroups" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; let i = index; trackBy: trackById" [formGroup]="talkgroup" cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
Talkgroups <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon> {{ talkgroup.value.label?.trim() || 'NewTalkgroup' }}
</mat-panel-title> <mat-icon *ngIf="talkgroup.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<div class="row top"> <rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
<p class="mat-caption">System talkgroups, you can drag and drop talkgroups to rearrange them. They (remove)="removeTalkgroup(i)">
will appear with the same order on the talkgroup selection panel unless the sort talkgroups </rdio-scanner-admin-talkgroup>
option is enabled.</p>
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64
[cdkDropListData]="talkgroups" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; index as i" [formGroup]="talkgroup"
cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ talkgroup.value.label?.trim() || 'NewTalkgroup' }}
<mat-icon *ngIf="talkgroup.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
(remove)="removeTalkgroup(i)">
</rdio-scanner-admin-talkgroup>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</ng-template> </ng-template>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
<mat-accordion displayMode="flat"> </ng-template>
<mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel-header> </mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Units
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">Create units to display the label instead of showing the unit id on the main
screen.<br>You can drag and drop them to rearrange</p>
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="units"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let unit of units; let i = index; trackBy: trackById" [formGroup]="unit" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
Units <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon> {{ unit.value.label?.trim() || 'NewUnit' }}
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<ng-template matExpansionPanelContent> <ng-template matExpansionPanelContent>
<div class="row top"> <rdio-scanner-admin-unit [form]="unit" (remove)="removeUnit(i)">
<p class="mat-caption">Create units to display the label instead of showing the unit id on the main </rdio-scanner-admin-unit>
screen.<br>You can drag and drop them to rearrange</p> </ng-template>
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button> </mat-expansion-panel>
</div> </mat-accordion>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="units" </ng-template>
(cdkDropListDropped)="drop($event)"> </mat-expansion-panel>
<mat-expansion-panel *ngFor="let unit of units; index as i" [formGroup]="unit" cdkDrag> </mat-accordion>
<mat-expansion-panel-header> <div class="row bottom">
<mat-panel-title> <button type="button" mat-button color="warn" (click)="remove.emit()">
<mat-icon cdkDragHandle>drag_indicator</mat-icon> Delete system
{{ unit.value.label?.trim() || 'NewUnit' }} </button>
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon> </div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-unit [form]="unit" (remove)="removeUnit(i)">
</rdio-scanner-admin-unit>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete system
</button>
</div>
</ng-container> </ng-container>

View file

@ -27,6 +27,7 @@ import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-system', selector: 'rdio-scanner-admin-system',
templateUrl: './system.component.html', templateUrl: './system.component.html',
standalone: false
}) })
export class RdioScannerAdminSystemComponent { export class RdioScannerAdminSystemComponent {
@Input() form = new FormGroup({}); @Input() form = new FormGroup({});
@ -47,23 +48,22 @@ export class RdioScannerAdminSystemComponent {
get sites(): FormGroup[] { get sites(): FormGroup[] {
const sites = this.form.get('sites') as FormArray | null; const sites = this.form.get('sites') as FormArray | null;
return (sites?.controls as FormGroup[]) || [];
return sites?.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
} }
get talkgroups(): FormGroup[] { get talkgroups(): FormGroup[] {
const talkgroups = this.form.get('talkgroups') as FormArray | null; const talkgroups = this.form.get('talkgroups') as FormArray | null;
return (talkgroups?.controls as FormGroup[]) || [];
return talkgroups?.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
} }
get units(): FormGroup[] { get units(): FormGroup[] {
const units = this.form.get('units') as FormArray | null; const units = this.form.get('units') as FormArray | null;
return (units?.controls as FormGroup[]) || [];
}
return units?.controls // trackBy to preserve identity in ngFor lists
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[]; trackById(_index: number, group: FormGroup): any {
return group.get('id')?.value ?? _index;
} }
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined; @ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;

View file

@ -1,20 +1,20 @@
<div class="row top"> <div class="row top">
<p class="mat-body">This is where you define all your systems and talkgroups are defined. Audio files to unknown <p class="mat-body">This is where you define all your systems and talkgroups are defined. Audio files to unknown
systems/talkgroups won't be ingested unless the auto populate option is activated. You can also drag and drop systems/talkgroups won't be ingested unless the auto populate option is activated. You can also drag and drop
systems to rearrange their order.</p> systems to rearrange their order.</p>
<button type="button" mat-button color="accent" (click)="add()">New system</button> <button type="button" mat-button color="accent" (click)="add()">New system</button>
</div> </div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)"> <mat-accordion *ngIf="systems" displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let system of systems; index as i" [formGroup]="system" <mat-expansion-panel *ngFor="let system of systems; let i = index" [formGroup]="system"
(afterCollapse)="systemComponent.closeAll()" cdkDrag> (afterCollapse)="systemComponent.closeAll()" cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ system.value.label?.trim() || 'NewSystem' }} {{ system.value.label?.trim() || 'NewSystem' }}
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)"> <rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
</rdio-scanner-admin-system> </rdio-scanner-admin-system>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>

View file

@ -18,26 +18,44 @@
*/ */
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core'; import { Component, Input, QueryList, ViewChildren, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms'; import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion'; import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service'; import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'rdio-scanner-admin-systems', selector: 'rdio-scanner-admin-systems',
templateUrl: './systems.component.html', templateUrl: './systems.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RdioScannerAdminSystemsComponent { export class RdioScannerAdminSystemsComponent implements OnDestroy {
@Input() form: FormArray | undefined; private _form: FormArray | undefined;
private subs = new Subscription();
private _systemsCache: FormGroup[] = [];
@Input()
set form(f: FormArray | undefined) {
if (this._form === f) { return; }
this.subs.unsubscribe();
this.subs = new Subscription();
this._form = f;
this.updateCache();
if (this._form) {
const s = this._form.valueChanges.subscribe(() => this.updateCache());
this.subs.add(s);
}
}
get form(): FormArray | undefined { return this._form; }
get systems(): FormGroup[] { get systems(): FormGroup[] {
return this.form?.controls return this._systemsCache;
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
} }
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined; @ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { } constructor(private adminService: RdioScannerAdminService, private cd: ChangeDetectorRef) { }
add(): void { add(): void {
const system = this.adminService.newSystemForm(); const system = this.adminService.newSystemForm();
@ -47,6 +65,8 @@ export class RdioScannerAdminSystemsComponent {
this.form?.insert(0, system); this.form?.insert(0, system);
this.form?.markAsDirty(); this.form?.markAsDirty();
this.updateCache();
} }
closeAll(): void { closeAll(): void {
@ -60,6 +80,8 @@ export class RdioScannerAdminSystemsComponent {
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false })); event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty(); this.form?.markAsDirty();
this.updateCache();
} }
} }
@ -67,5 +89,23 @@ export class RdioScannerAdminSystemsComponent {
this.form?.removeAt(index); this.form?.removeAt(index);
this.form?.markAsDirty(); this.form?.markAsDirty();
this.updateCache();
}
private updateCache(): void {
if (!this._form) {
this._systemsCache = [];
this.cd.markForCheck();
return;
}
const arr = [...this._form.controls] as FormGroup[];
arr.sort((a, b) => (a.value.order || 0) - (b.value.order || 0));
this._systemsCache = arr;
this.cd.markForCheck();
}
ngOnDestroy(): void {
this.subs.unsubscribe();
} }
} }

View file

@ -1,168 +1,170 @@
<ng-container *ngIf="form" [formGroup]="form"> <ng-container *ngIf="form">
<ng-container [formGroup]="form">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Id</span><br> <span class="mat-body">Id</span><br>
<span class="mat-caption">Talkgroup identifier in decimal format.</span> <span class="mat-caption">Talkgroup identifier in decimal format.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="talkgroupRef" placeholder="Id"> <input type="number" min="1" step="1" matInput formControlName="talkgroupRef" placeholder="Id">
<mat-error *ngIf="form?.get('talkgroupRef')?.hasError('duplicate')"> <mat-error *ngIf="form.get('talkgroupRef')!.hasError('duplicate')">
Id is already defined Id is already defined
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('talkgroupRef')?.hasError('min')"> <mat-error *ngIf="form.get('talkgroupRef')!.hasError('min')">
Id is invalid Id is invalid
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('talkgroupRef')?.hasError('required')"> <mat-error *ngIf="form.get('talkgroupRef')!.hasError('required')">
Id is required Id is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Label</span><br> <span class="mat-body">Label</span><br>
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span> <span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label"> <input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')"> <mat-error *ngIf="form.get('label')!.hasError('required')">
Label is required Label is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Name</span><br> <span class="mat-body">Name</span><br>
<span class="mat-caption">Talkgroup name displayed on the main screen.</span> <span class="mat-caption">Talkgroup name displayed on the main screen.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="name" placeholder="Name"> <input type="text" matInput formControlName="name" placeholder="Name">
<mat-error *ngIf="form?.get('name')?.hasError('required')"> <mat-error *ngIf="form.get('name')!.hasError('required')">
Name is required Name is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Groups</span><br> <span class="mat-body">Groups</span><br>
<span class="mat-caption">Groups to which this talkgoups belongs.</span> <span class="mat-caption">Groups to which this talkgroups belongs.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="groupIds" placeholder="Groups" [multiple]="true"> <mat-select formControlName="groupIds" placeholder="Groups" [multiple]="true">
<mat-option *ngFor="let group of groups" [value]="group.id"> <mat-option *ngFor="let group of groups" [value]="group.id">
{{ group.label }} {{ group.label }}
</mat-option> </mat-option>
</mat-select> </mat-select>
<mat-error *ngIf="form?.get('groupId')?.hasError('required')"> <mat-error *ngIf="form.get('groupIds')?.hasError('required')">
Group is required Group is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Tag</span><br> <span class="mat-body">Tag</span><br>
<span class="mat-caption">Tag to which this talkgoups belongs.</span> <span class="mat-caption">Tag to which this talkgroups belongs.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="tagId" placeholder="Tag"> <mat-select formControlName="tagId" placeholder="Tag">
<mat-option *ngFor="let tag of tags" [value]="tag.id"> <mat-option *ngFor="let tag of tags" [value]="tag.id">
{{ tag.label }} {{ tag.label }}
</mat-option> </mat-option>
</mat-select> </mat-select>
<mat-error *ngIf="form?.get('tagId')?.hasError('required')"> <mat-error *ngIf="form.get('tagId')!.hasError('required')">
Tag is required Tag is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Audio alert</span><br> <span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Prefixes an alert sound to all calls assigned from this talkgroup. The alert <span class="mat-caption">Prefixes an alert sound to all calls assigned from this talkgroup. The alert
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)"> <mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event.value)">
<mat-option value="">No Alert</mat-option> <mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert"> <mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }} {{ alert | titlecase }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Delay</span><br> <span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span> <span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes"> <input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Frequency</span><br> <span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span> <span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="0" matInput formControlName="frequency" placeholder="Frequency"> <input type="number" min="0" matInput formControlName="frequency" placeholder="Frequency">
<mat-error *ngIf="form?.get('frequency')?.errors"> <mat-error *ngIf="form.get('frequency')?.errors">
Invalid frequency Invalid frequency
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Led Color</span><br> <span class="mat-body">Led Color</span><br>
<span class="mat-caption">The default LED color is green but it can be changed at several levels. <span class="mat-caption">The default LED color is green but it can be changed at several levels.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color"> <mat-select formControlName="led" placeholder="Color">
<mat-option [value]="''">Default</mat-option> <mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led"> <mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }} {{ led | titlecase }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Type</span><br> <span class="mat-body">Type</span><br>
<span class="mat-caption"> <span class="mat-caption">
Type of radio system that will override the type defined at the system level, if any. Type of radio system that will override the type defined at the system level, if any.
<ul> <ul>
<li><b>AM</b> - Amplitude modulation.</li> <li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li> <li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li> <li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li> <li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li> <li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li> <li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li> <li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li> <li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li> <li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul> </ul>
</span> </span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type"> <mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option> <mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option> <mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option> <mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option> <mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option> <mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option> <mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option> <mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option> <mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option> <mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option> <mat-option value="tetra">Tetra</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row bottom"> <div class="row bottom">
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()"> <button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
Blacklist talkgroup Blacklist talkgroup
</button> </button>
<button type="button" mat-button color="warn" (click)="remove.emit()"> <button type="button" mat-button color="warn" (click)="remove.emit()">
Delete talkgroup Delete talkgroup
</button> </button>
</div> </div>
</ng-container>
</ng-container> </ng-container>

View file

@ -19,12 +19,12 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service'; import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-talkgroup', selector: 'rdio-scanner-admin-talkgroup',
templateUrl: './talkgroup.component.html', templateUrl: './talkgroup.component.html',
standalone: false
}) })
export class RdioScannerAdminTalkgroupComponent { export class RdioScannerAdminTalkgroupComponent {
@Input() form: FormGroup | undefined; @Input() form: FormGroup | undefined;
@ -33,25 +33,25 @@ export class RdioScannerAdminTalkgroupComponent {
@Output() remove = new EventEmitter<void>(); @Output() remove = new EventEmitter<void>();
leds: string[]; leds: string[] = [];
get alerts(): string[] { get alerts(): string[] {
return Object.keys(this.adminService.Alerts || {}); return Object.keys(this.adminService.Alerts || {});
} }
get groups(): Group[] { get groups(): Group[] {
return this.form?.root.get('groups')?.value as Group[]; return (this.form?.root.get('groups')?.value as Group[]) || [];
} }
get tags(): Tag[] { get tags(): Tag[] {
return this.form?.root.get('tags')?.value as Tag[]; return (this.form?.root.get('tags')?.value as Tag[]) || [];
} }
constructor(private adminService: RdioScannerAdminService) { constructor(private adminService: RdioScannerAdminService) {
this.leds = this.adminService.getLeds(); this.leds = this.adminService.getLeds() || [];
} }
async playAlert(event: MatSelectChange): Promise<void> { async playAlert(value: string | null): Promise<void> {
if (event.value) await this.adminService.playAlert(event.value); if (value) await this.adminService.playAlert(value);
} }
} }

View file

@ -1,68 +1,52 @@
<ng-container *ngIf="form" [formGroup]="form"> <ng-container *ngIf="form as f">
<ng-container [formGroup]="f">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Id</span><br> <span class="mat-body">Id</span><br>
<span class="mat-caption">Unit identifier in decimal format. You can define a unique unit identifier here, <span class="mat-caption">Unit identifier in decimal format. You can define a unique unit identifier here,
but you can also use a range of identifiers using the fields below.</span> but you can also use a range of identifiers using the fields below.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitRef" placeholder="Id"> <input type="number" min="1" step="1" matInput formControlName="unitRef" placeholder="Id">
<mat-error *ngIf="form?.get('unitRef')?.hasError('duplicate')"> <mat-error *ngIf="f.get('unitRef')!.hasError('duplicate')">Id value is already defined</mat-error>
Id value is already defined <mat-error *ngIf="f.get('unitRef')!.hasError('min')">Id value is invalid</mat-error>
</mat-error> <mat-error *ngIf="f.get('unitRef')!.hasError('required')">Id value is required</mat-error>
<mat-error *ngIf="form?.get('unitRef')?.hasError('min')"> </mat-form-field>
Id value is invalid
</mat-error>
<mat-error *ngIf="form?.get('unitRef')?.hasError('required')">
Id value is required
</mat-error>
</mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Range from</span><br> <span class="mat-body">Range from</span><br>
<span class="mat-caption">Unit identifier range from.</span> <span class="mat-caption">Unit identifier range from.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitFrom" placeholder="From"> <input type="number" min="1" step="1" matInput formControlName="unitFrom" placeholder="From">
<mat-error *ngIf="form?.get('unitFrom')?.hasError('range')"> <mat-error *ngIf="f.get('unitFrom')!.hasError('range')">From value should be lower than To value</mat-error>
From value should be lower than To value <mat-error *ngIf="f.get('unitFrom')!.hasError('min')">From value is invalid</mat-error>
</mat-error> </mat-form-field>
<mat-error *ngIf="form?.get('unitFrom')?.hasError('min')">
From value To is invalid
</mat-error>
</mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Range to</span><br> <span class="mat-body">Range to</span><br>
<span class="mat-caption">Unit identifier range to.</span> <span class="mat-caption">Unit identifier range to.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitTo" placeholder="To"> <input type="number" min="1" step="1" matInput formControlName="unitTo" placeholder="To">
<mat-error *ngIf="form?.get('unitTo')?.hasError('range')"> <mat-error *ngIf="f.get('unitTo')!.hasError('range')">To value should be higher than From value</mat-error>
To value should be higher than From value <mat-error *ngIf="f.get('unitTo')!.hasError('min')">To value is invalid</mat-error>
</mat-error> </mat-form-field>
<mat-error *ngIf="form?.get('unitTo')?.hasError('min')">
To value is invalid
</mat-error>
</mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Label</span><br> <span class="mat-body">Label</span><br>
<span class="mat-caption">Unit label.</span> <span class="mat-caption">Unit label.</span>
</p> </p>
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label"> <input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')"> <mat-error *ngIf="f.get('label')!.hasError('required')">Label is required</mat-error>
Label is required </mat-form-field>
</mat-error>
</mat-form-field>
</div> </div>
<div class="row bottom"> <div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()"> <button type="button" mat-button color="warn" (click)="remove.emit()">Delete unit</button>
Delete unit
</button>
</div> </div>
</ng-container>
</ng-container> </ng-container>

View file

@ -17,15 +17,17 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, ChangeDetectionStrategy, EventEmitter, Input, Output } from '@angular/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
@Component({ @Component({
selector: 'rdio-scanner-admin-unit', selector: 'rdio-scanner-admin-unit',
templateUrl: './unit.component.html', templateUrl: './unit.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RdioScannerAdminUnitComponent { export class RdioScannerAdminUnitComponent {
@Input() form: FormGroup | undefined; @Input() form: FormGroup | null = null;
@Output() remove = new EventEmitter<void>(); @Output() readonly remove = new EventEmitter<void>();
} }

View file

@ -1,67 +1,79 @@
<div class="row top"> <div class="row top">
<p class="mat-body">All system talkgroups must be associated with a tag, which is then used to search for calls <p class="mat-body">All system talkgroups must be associated with a tag, which is then used to search for calls
based on their tag.</p> based on their tag.</p>
<button type="button" mat-button color="accent" (click)="add()">New Tag</button> <button type="button" mat-button color="accent" (click)="add()">New Tag</button>
</div> </div>
<p *ngIf="!tags?.length" class="mat-small text-center">No defined Tags</p> @if (!tags.length) {
<p class="mat-small text-center">No defined Tags</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="tags" <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="tags"
(cdkDropListDropped)="drop($event)"> (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let tag of tags; index as i" cdkDrag> @for (tag of tags; track tag; let i = $index) {
<mat-expansion-panel-header> <mat-expansion-panel cdkDrag>
<mat-panel-title> <mat-expansion-panel-header>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-panel-title>
{{ tag.value.label || 'NewTag' }} <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-icon *ngIf="tag.invalid" color="warn">error</mat-icon> {{ tag.value.label || 'NewTag' }}
</mat-panel-title> @if (tag.invalid) {
</mat-expansion-panel-header> <mat-icon color="warn">error</mat-icon>
<ng-container [formGroup]="tag"> }
<div class="row"> </mat-panel-title>
<p> </mat-expansion-panel-header>
<span class="mat-body">Label</span><br> <ng-container [formGroup]="tag">
<span class="mat-caption">Tag label displayed on the main screen and on the search panel.</span> <div class="row">
</p> <p>
<mat-form-field floatLabel="auto"> <span class="mat-body">Label</span><br>
<input type="text" matInput formControlName="label" placeholder="Label"> <span class="mat-caption">Tag label displayed on the main screen and on the search panel.</span>
<mat-error *ngIf="form?.get('label')?.hasError('required')"> </p>
Label is required <mat-form-field floatLabel="auto">
</mat-error> <input type="text" matInput formControlName="label" placeholder="Label">
</mat-form-field> @if (tag.get('label')!.hasError('required')) {
</div> <mat-error>
<div class="row"> Label is required
<p> </mat-error>
<span class="mat-body">Audio alert</span><br> }
<span class="mat-caption">Prefixes an alert sound to all calls assigned to this tag. The alert </mat-form-field>
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> </div>
</p> <div class="row">
<mat-form-field floatLabel="auto"> <p>
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)"> <span class="mat-body">Audio alert</span><br>
<mat-option value="">No Alert</mat-option> <span class="mat-caption">Prefixes an alert sound to all calls assigned to this tag. The alert
<mat-option *ngFor="let alert of alerts" [value]="alert"> assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
{{ alert | titlecase }} </p>
</mat-option> <mat-form-field floatLabel="auto">
</mat-select> <mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
</mat-form-field> <mat-option value="">No Alert</mat-option>
</div> @for (alert of alerts; track alert) {
<div class="row"> <mat-option [value]="alert">
<p> {{ alert | titlecase }}
<span class="mat-body">Led Color</span><br> </mat-option>
<span class="mat-caption">The default LED color is green but it can be changed at several levels. }
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span> </mat-select>
</p> </mat-form-field>
<mat-form-field floatLabel="auto"> </div>
<mat-select formControlName="led" placeholder="Color"> <div class="row">
<mat-option [value]="''">Default</mat-option> <p>
<mat-option *ngFor="let led of leds" [value]="led"> <span class="mat-body">Led Color</span><br>
{{ led | titlecase }} <span class="mat-caption">The default LED color is green but it can be changed at several levels.
</mat-option> The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</mat-select> </p>
</mat-form-field> <mat-form-field floatLabel="auto">
</div> <mat-select formControlName="led" placeholder="Color">
<div class="row bottom"> <mat-option [value]="''">Default</mat-option>
<button type="button" mat-button color="warn" (click)="remove(i)"> @for (led of leds; track led) {
Delete Tag <mat-option [value]="led">
</button> {{ led | titlecase }}
</div> </mat-option>
</ng-container> }
</mat-select>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete Tag
</button>
</div>
</ng-container>
</mat-expansion-panel> </mat-expansion-panel>
}
</mat-accordion> </mat-accordion>

View file

@ -28,6 +28,7 @@ import { RdioScannerAdminService } from '../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-tags', selector: 'rdio-scanner-admin-tags',
templateUrl: './tags.component.html', templateUrl: './tags.component.html',
standalone: false
}) })
export class RdioScannerAdminTagsComponent { export class RdioScannerAdminTagsComponent {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;

View file

@ -2,8 +2,17 @@
<p class="mat-body-2">Please enter the admin password to gain access to the administrative dashboard</p> <p class="mat-body-2">Please enter the admin password to gain access to the administrative dashboard</p>
<mat-form-field hideRequiredMarker> <mat-form-field hideRequiredMarker>
<mat-label>Password</mat-label> <mat-label>Password</mat-label>
<input matInput formControlName="password" type="password" required> <input
<mat-error>{{ message }}</mat-error> matInput
formControlName="password"
type="password"
autocomplete="current-password"
[attr.aria-invalid]="form.get('password')?.invalid"
required
>
<mat-error *ngIf="(form.get('password')?.invalid && form.get('password')?.touched) || message">
{{ message || 'Password is required' }}
</mat-error>
</mat-form-field> </mat-form-field>
<button mat-raised-button color="primary" type="submit" [disabled]="form.disabled || form.invalid">Login</button> <button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || form.disabled">Login</button>
</form> </form>

View file

@ -25,6 +25,7 @@ import { RdioScannerAdminService } from '../admin.service';
selector: 'rdio-scanner-admin-login', selector: 'rdio-scanner-admin-login',
styleUrls: ['./login.component.scss'], styleUrls: ['./login.component.scss'],
templateUrl: './login.component.html', templateUrl: './login.component.html',
standalone: false
}) })
export class RdioScannerAdminLoginComponent { export class RdioScannerAdminLoginComponent {
@Output() loggedIn = new EventEmitter<void>(); @Output() loggedIn = new EventEmitter<void>();

View file

@ -1,85 +1,91 @@
<mat-table [dataSource]="logs"> <mat-table [dataSource]="logs">
<ng-container matColumnDef="level"> <ng-container matColumnDef="level">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Level</span> <span>Level</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let log"> <mat-cell *matCellDef="let log">
<mat-icon *ngIf="log?.level === 'error'" class="error">error</mat-icon> @if (log?.level === 'error') {
<mat-icon *ngIf="log?.level === 'info'" class="info">notifications</mat-icon> <mat-icon class="error">error</mat-icon>
<mat-icon *ngIf="log?.level === 'warn'" class="warn">warning</mat-icon> }
</mat-cell> @if (log?.level === 'info') {
</ng-container> <mat-icon class="info">notifications</mat-icon>
<ng-container matColumnDef="date"> }
<mat-header-cell *matHeaderCellDef> @if (log?.level === 'warn') {
<span>Date</span> <mat-icon class="warn">warning</mat-icon>
</mat-header-cell> }
<mat-cell *matCellDef="let log"> </mat-cell>
<span>{{ log?.dateTime | date:'MM/dd' }}</span> </ng-container>
</mat-cell> <ng-container matColumnDef="date">
</ng-container> <mat-header-cell *matHeaderCellDef>
<ng-container matColumnDef="time"> <span>Date</span>
<mat-header-cell *matHeaderCellDef> </mat-header-cell>
<span>Time</span> <mat-cell *matCellDef="let log">
</mat-header-cell> <span>{{ log?.dateTime | date:'MM/dd' }}</span>
<mat-cell *matCellDef="let log"> </mat-cell>
<span>{{ log?.dateTime | date:'HH:mm' }}</span> </ng-container>
</mat-cell> <ng-container matColumnDef="time">
</ng-container> <mat-header-cell *matHeaderCellDef>
<ng-container matColumnDef="message"> <span>Time</span>
<mat-header-cell *matHeaderCellDef> </mat-header-cell>
<span>Message</span> <mat-cell *matCellDef="let log">
</mat-header-cell> <span>{{ log?.dateTime | date:'HH:mm' }}</span>
<mat-cell *matCellDef="let log"> </mat-cell>
<span>{{ log?.message }}</span> </ng-container>
</mat-cell> <ng-container matColumnDef="message">
</ng-container> <mat-header-cell *matHeaderCellDef>
<mat-header-row *matHeaderRowDef="['level', 'date', 'time', 'message']"> <span>Message</span>
</mat-header-row> </mat-header-cell>
<mat-row *matRowDef="let row; columns: ['level', 'date', 'time', 'message']"> <mat-cell *matCellDef="let log">
</mat-row> <span>{{ log?.message }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['level', 'date', 'time', 'message']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['level', 'date', 'time', 'message']">
</mat-row>
</mat-table> </mat-table>
<mat-progress-bar color="primary" [mode]="logsQueryPending ? 'query' : 'determinate'"> <mat-progress-bar color="primary" [mode]="logsQueryPending ? 'query' : 'determinate'">
</mat-progress-bar> </mat-progress-bar>
<mat-paginator [disabled]="logsQueryPending" [length]="logsQuery?.count" [hidePageSize]="true" <mat-paginator [disabled]="logsQueryPending" [length]="logsQuery?.count" [hidePageSize]="true"
[pageSize]="logs.value.length" [showFirstLastButtons]="true" (page)="refresh()"> [pageSize]="logs.value.length" [showFirstLastButtons]="true" (page)="refresh()">
</mat-paginator> </mat-paginator>
<form autocomplete="off" [formGroup]="form"> <form autocomplete="off" [formGroup]="form">
<mat-form-field> <mat-form-field>
<mat-label> <mat-label>
Sort order Sort order
</mat-label> </mat-label>
<mat-select formControlName="sort" (selectionChange)="formHandler()"> <mat-select formControlName="sort" (selectionChange)="formHandler()">
<mat-option [value]="1"> <mat-option [value]="1">
Ascending Ascending
</mat-option> </mat-option>
<mat-option [value]="-1"> <mat-option [value]="-1">
Descending Descending
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label> <mat-label>
Date Date
</mat-label> </mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="logsQuery?.dateStop" <input matInput type="datetime-local" formControlName="date" [max]="logsQuery?.dateStop"
[min]="logsQuery?.dateStart" placeholder="Choose a date" (change)="formHandler()"> [min]="logsQuery?.dateStart" placeholder="Choose a date" (change)="formHandler()">
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label> <mat-label>
Level Level
</mat-label> </mat-label>
<mat-select formControlName="level" (selectionChange)="formHandler()"> <mat-select formControlName="level" (selectionChange)="formHandler()">
<mat-option value="info">Informative</mat-option> <mat-option value="info">Informative</mat-option>
<mat-option value="warn">Warning</mat-option> <mat-option value="warn">Warning</mat-option>
<mat-option value="error">Error</mat-option> <mat-option value="error">Error</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<div class="reset"> <div class="reset">
<button type="button" mat-raised-button [disabled]="logsQueryPending" (click)="reload()"> <button type="button" mat-raised-button [disabled]="logsQueryPending" (click)="reload()">
Reload Reload
</button> </button>
<button type="button" mat-raised-button [disabled]="logsQueryPending || form.pristine" (click)="reset()"> <button type="button" mat-raised-button [disabled]="logsQueryPending || form.pristine" (click)="reset()">
Reset Reset
</button> </button>
</div> </div>
</form> </form>

View file

@ -27,6 +27,7 @@ import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../ad
selector: 'rdio-scanner-admin-logs', selector: 'rdio-scanner-admin-logs',
styleUrls: ['./logs.component.scss'], styleUrls: ['./logs.component.scss'],
templateUrl: './logs.component.html', templateUrl: './logs.component.html',
standalone: false
}) })
export class RdioScannerAdminLogsComponent { export class RdioScannerAdminLogsComponent {
form: FormGroup; form: FormGroup;

View file

@ -1,12 +1,14 @@
<mat-card appearance="outlined" *ngIf="todos.length"> <ng-container *ngIf="todos.length">
<mat-card appearance="outlined">
<mat-card-title>Todos</mat-card-title> <mat-card-title>Todos</mat-card-title>
<mat-list> <mat-list>
<mat-list-item *ngFor="let todo of todos"> <mat-list-item *ngFor="let todo of todos">
<mat-icon *ngIf="todo.level === 'info'" class="info" matListItemIcon>notifications</mat-icon> <mat-icon *ngIf="todo.level === 'info'" class="info" matListItemIcon>notifications</mat-icon>
<mat-icon *ngIf="todo.level === 'warn'" class="warn" matListItemIcon>warning</mat-icon> <mat-icon *ngIf="todo.level === 'warn'" class="warn" matListItemIcon>warning</mat-icon>
<div mat-line> <div mat-line>
<span class="todo">{{ todo.message }}</span> <span class="todo">{{ todo.message }}</span>
</div> </div>
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
</mat-card> </mat-card>
</ng-container>

View file

@ -1,12 +1,12 @@
.info { .mat-mdc-list-item .info {
color: green; color: green;
} }
.warn { .mat-mdc-list-item .warn {
color: orange; color: orange;
} }
.todo { .mat-mdc-list-item .todo {
text-overflow: unset; text-overflow: unset;
white-space: normal; white-space: normal;
} }

View file

@ -29,6 +29,7 @@ interface Todo {
selector: 'rdio-scanner-admin-todos', selector: 'rdio-scanner-admin-todos',
styleUrls: ['./todos.component.scss'], styleUrls: ['./todos.component.scss'],
templateUrl: './todos.component.html', templateUrl: './todos.component.html',
standalone: false
}) })
export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit { export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
todos: Todo[] = []; todos: Todo[] = [];

View file

@ -17,8 +17,8 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { DOCUMENT } from '@angular/common';
import { Component, EventEmitter, Inject, Output } from '@angular/core'; import { Component, EventEmitter, Inject, Output, DOCUMENT } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Config, RdioScannerAdminService } from '../../admin.service'; import { Config, RdioScannerAdminService } from '../../admin.service';
import packageInfo from '../../../../../../../package.json'; import packageInfo from '../../../../../../../package.json';
@ -27,6 +27,7 @@ import packageInfo from '../../../../../../../package.json';
selector: 'rdio-scanner-admin-import-export-config', selector: 'rdio-scanner-admin-import-export-config',
styleUrls: ['./import-export-config.component.scss'], styleUrls: ['./import-export-config.component.scss'],
templateUrl: './import-export-config.component.html', templateUrl: './import-export-config.component.html',
standalone: false
}) })
export class RdioScannerAdminImportExportConfigComponent { export class RdioScannerAdminImportExportConfigComponent {
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();

View file

@ -1,113 +1,119 @@
<p class="mat-body note">Before going any further, note that there is no need to preload talkgroups into a system as the <p class="mat-body note">Before going any further, note that there is no need to preload talkgroups into a system as the
autopopulate feature will automatically create them as audio files are ingested. So give the autopopulate feature a autopopulate feature will automatically create them as audio files are ingested. So give the autopopulate feature a
chance before trying to import talkgroups.</p> chance before trying to import talkgroups.</p>
<section> <section>
<p class="mat-body">step 1: read the CSV file</p> <p class="mat-body">step 1: read the CSV file</p>
<p class="text-center"> <p class="text-center">
<ng-container *ngIf="csv.length === 0"> @if (csv.length === 0) {
<button mat-raised-button (click)="input.click()">Read the CSV file</button> <button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)"> <input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
<br> <br>
<p> <p>
<span class="mat-body-1">Column format is: </span> <span class="mat-body-1">Column format is: </span>
<span class="mat-body-1">Id</span>, <span class="mat-body-1">Id</span>,
<span>unused</span>, <span>unused</span>,
<span class="mat-body-1">Label</span>, <span class="mat-body-1">Label</span>,
<span>unused</span>, <span>unused</span>,
<span class="mat-body-1">Name</span>, <span class="mat-body-1">Name</span>,
<span class="mat-body-1">Tag</span>, <span class="mat-body-1">Tag</span>,
<span class="mat-body-1">Group</span> <span class="mat-body-1">Group</span>
</p> </p>
</ng-container> }
<ng-container *ngIf="csv.length !== 0"> @if (csv.length !== 0) {
<button mat-raised-button (click)="csv=[]">Reset</button> <button mat-raised-button (click)="csv=[]">Reset</button>
</ng-container> }
</p> </p>
</section> </section>
<section> <section>
<p class="mat-body">step 2: select system</p> <p class="mat-body">step 2: select system</p>
<p class="text-center"> <p class="text-center">
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select [(ngModel)]="system"> <mat-select [(ngModel)]="system">
<mat-option *ngFor="let system of baseConfig.systems" [value]="system"> @for (system of baseConfig.systems; track system) {
{{ system.label}} <mat-option [value]="system">
</mat-option> {{ system.label}}
</mat-select> </mat-option>
</mat-form-field> }
</mat-select>
</mat-form-field>
</p> </p>
</section> </section>
<section> <section>
<p class="mat-body">step 3: review talkgroups to import</p> <p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll"> <div class="scroll">
<mat-table #table [dataSource]="csv"> <mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Id</span> <span>Id</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[0] }}</span> <span>{{ tg[0] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="label"> <ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Label</span> <span>Label</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[2] }}</span> <span>{{ tg[2] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="description"> <ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Description</span> <span>Description</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[4] }}</span> <span>{{ tg[4] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="tag"> <ng-container matColumnDef="tag">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Tag</span> <span>Tag</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[5] }}</span> <span>{{ tg[5] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="group"> <ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Group</span> <span>Group</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[6] }}</span> <span>{{ tg[6] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="action"> <ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell> <mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i"> <mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()"> <button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon> <mat-icon>clear</mat-icon>
</button> </button>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row> <mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row> <mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table> </mat-table>
</div> </div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p> @if (!csv.length) {
</section> <p class="mat-body text-center">No CSV data yet</p>
}
</section>
<section> <section>
<p class="mat-body">step 4: import to configuration</p> <p class="mat-body">step 4: import to configuration</p>
<p class="text-center"> <p class="text-center">
<button mat-raised-button [disabled]="!csv.length" (click)="import()"> <button mat-raised-button [disabled]="!csv.length" (click)="import()">
Import to configuration Import to configuration
</button> </button>
</p> </p>
<p *ngIf="csv.length > 800" class="mat-caption mat-error text-center text-uppercase"> @if (csv.length > 800) {
<p class="mat-caption mat-error text-center text-uppercase">
Warning, you are trying to import more than 800 talkgroups, {{csv.length}} in this case. The graphic interface Warning, you are trying to import more than 800 talkgroups, {{csv.length}} in this case. The graphic interface
has not designed for this number of talkgroups. You can continue, but expect Rdio Scanner to be quite slow, if has not designed for this number of talkgroups. You can continue, but expect Rdio Scanner to be quite slow, if
not unusable. not unusable.
</p> </p>
</section> }
</section>

View file

@ -24,6 +24,7 @@ import { Config, RdioScannerAdminService, System } from '../../admin.service';
selector: 'rdio-scanner-admin-import-talkgroups', selector: 'rdio-scanner-admin-import-talkgroups',
styleUrls: ['./import-talkgroups.component.scss'], styleUrls: ['./import-talkgroups.component.scss'],
templateUrl: './import-talkgroups.component.html', templateUrl: './import-talkgroups.component.html',
standalone: false
}) })
export class RdioScannerAdminImportTalkgroupsComponent implements OnInit { export class RdioScannerAdminImportTalkgroupsComponent implements OnInit {
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();

View file

@ -1,80 +1,86 @@
<section> <section>
<p class="mat-body">step 1: read the CSV file</p> <p class="mat-body">step 1: read the CSV file</p>
<p class="text-center"> <p class="text-center">
<ng-container *ngIf="csv.length === 0"> @if (csv.length === 0) {
<button mat-raised-button (click)="input.click()">Read the CSV file</button> <button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)"> <input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
<br> <br>
<p class="mat-body-1"> <p class="mat-body-1">
<span>Column format is: </span> <span>Column format is: </span>
<span>Id</span>, <span>Id</span>,
<span>Label</span>, <span>Label</span>,
</p> </p>
</ng-container> }
<ng-container *ngIf="csv.length !== 0"> @if (csv.length !== 0) {
<button mat-raised-button (click)="csv=[]">Reset</button> <button mat-raised-button (click)="csv=[]">Reset</button>
</ng-container> }
</p> </p>
</section> </section>
<section> <section>
<p class="mat-body">step 2: select system</p> <p class="mat-body">step 2: select system</p>
<p class="text-center"> <p class="text-center">
<mat-form-field floatLabel="auto"> <mat-form-field floatLabel="auto">
<mat-select [(ngModel)]="system"> <mat-select [(ngModel)]="system">
<mat-option *ngFor="let system of baseConfig.systems" [value]="system"> @for (system of baseConfig.systems; track system) {
{{ system.label}} <mat-option [value]="system">
</mat-option> {{ system.label}}
</mat-select> </mat-option>
</mat-form-field> }
</mat-select>
</mat-form-field>
</p> </p>
</section> </section>
<section> <section>
<p class="mat-body">step 3: review talkgroups to import</p> <p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll"> <div class="scroll">
<mat-table #table [dataSource]="csv"> <mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Id</span> <span>Id</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[0] }}</span> <span>{{ tg[0] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="label"> <ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef> <mat-header-cell *matHeaderCellDef>
<span>Label</span> <span>Label</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[1] }}</span> <span>{{ tg[1] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="action"> <ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell> <mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i"> <mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()"> <button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon> <mat-icon>clear</mat-icon>
</button> </button>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row> <mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row> <mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table> </mat-table>
</div> </div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p> @if (!csv.length) {
</section> <p class="mat-body text-center">No CSV data yet</p>
}
</section>
<section> <section>
<p class="mat-body">step 4: import to configuration</p> <p class="mat-body">step 4: import to configuration</p>
<p class="text-center"> <p class="text-center">
<button mat-raised-button [disabled]="!csv.length || system === undefined" (click)="import()"> <button mat-raised-button [disabled]="!csv.length || system === undefined" (click)="import()">
Import to configuration Import to configuration
</button> </button>
</p> </p>
<p *ngIf="csv.length > 1000" class="mat-caption mat-error text-center text-uppercase"> @if (csv.length > 1000) {
<p class="mat-caption mat-error text-center text-uppercase">
Warning, you are trying to import more than 1000 units, {{csv.length}} in this case. The graphic interface has Warning, you are trying to import more than 1000 units, {{csv.length}} in this case. The graphic interface has
not designed for this number of units. You can continue, but expect Rdio Scanner to be quite slow, if not not designed for this number of units. You can continue, but expect Rdio Scanner to be quite slow, if not
unusable. unusable.
</p> </p>
</section> }
</section>

View file

@ -24,6 +24,7 @@ import { Config, RdioScannerAdminService, System } from '../../admin.service';
selector: 'rdio-scanner-admin-import-units', selector: 'rdio-scanner-admin-import-units',
styleUrls: ['./import-units.component.scss'], styleUrls: ['./import-units.component.scss'],
templateUrl: './import-units.component.html', templateUrl: './import-units.component.html',
standalone: false
}) })
export class RdioScannerAdminImportUnitsComponent implements OnInit{ export class RdioScannerAdminImportUnitsComponent implements OnInit{
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();

View file

@ -1,38 +1,39 @@
<p class="mat-body"> <p class="mat-body">
Change the administrator password which gives access to the server administration dashboard. Change the administrator password which gives access to the server administration dashboard.
</p> </p>
<form [formGroup]="form"> <form [formGroup]="form" (ngSubmit)="save()">
<mat-form-field> <mat-form-field>
<mat-label>Current password</mat-label> <input matInput type="text" name="username" value="admin" autocomplete="username" tabindex="-1" aria-label="Username (for password manager)" readonly>
<input type="password" matInput formControlName="currentPassword" required> </mat-form-field>
<mat-error *ngIf="form.get('currentPassword')?.hasError('required')"> <mat-form-field>
Current password is required <mat-label>Current password</mat-label>
</mat-error> <input type="password" matInput formControlName="currentPassword" required autocomplete="current-password">
</mat-form-field> <mat-error *ngIf="form.get('currentPassword')?.hasError('required')">
<mat-form-field> Current password is required
<mat-label>New password</mat-label> </mat-error>
<input type="password" matInput formControlName="newPassword" required> </mat-form-field>
<mat-error *ngIf="form.get('newPassword')?.hasError('required')"> <mat-form-field>
New password is required <mat-label>New password</mat-label>
</mat-error> <input type="password" matInput formControlName="newPassword" required minlength="8" autocomplete="new-password">
<mat-error *ngIf="form.get('newPassword')?.hasError('minlength')"> <mat-error *ngIf="form.get('newPassword')?.hasError('required')">
Password is too short New password is required
</mat-error> </mat-error>
</mat-form-field> <mat-error *ngIf="form.get('newPassword')?.hasError('minlength')">
<mat-form-field> Password is too short
<mat-label>Verify new password</mat-label> </mat-error>
<input type="password" matInput formControlName="verifyNewPassword" required> </mat-form-field>
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')"> <mat-form-field>
New password verification is required <mat-label>Verify new password</mat-label>
</mat-error> <input type="password" matInput formControlName="verifyNewPassword" required autocomplete="new-password">
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('invalid')"> <mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')">
Password don't match New password verification is required
</mat-error> </mat-error>
</mat-form-field> <mat-error *ngIf="form.hasError('passwordMismatch')">
<div class="row bottom"> Passwords don't match
<button type="button" mat-raised-button [disabled]="form.disabled || form.untouched" </mat-error>
(click)="reset();">Reset</button> </mat-form-field>
<button type="button" mat-raised-button color="primary" <div class="row bottom">
[disabled]="form.disabled || form.pristine || !form.valid" (click)="save()">Save</button> <button type="button" mat-raised-button [disabled]="form.disabled || form.untouched" (click)="reset()">Reset</button>
</div> <button type="submit" mat-raised-button color="primary" [disabled]="form.disabled || form.pristine || form.invalid">Save</button>
</div>
</form> </form>

View file

@ -18,61 +18,68 @@
*/ */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, ValidatorFn, ValidationErrors, Validators } from '@angular/forms'; import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { RdioScannerAdminService } from '../../admin.service'; import { RdioScannerAdminService } from '../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-password', selector: 'rdio-scanner-admin-password',
styleUrls: ['./password.component.scss'], styleUrls: ['./password.component.scss'],
templateUrl: './password.component.html', templateUrl: './password.component.html',
standalone: false
}) })
export class RdioScannerAdminPasswordComponent { export class RdioScannerAdminPasswordComponent {
form: FormGroup; form: FormGroup;
constructor( constructor(
private adminService: RdioScannerAdminService, private readonly adminService: RdioScannerAdminService,
private matSnackBar: MatSnackBar, private readonly matSnackBar: MatSnackBar,
private ngFormBuilder: FormBuilder, private readonly ngFormBuilder: FormBuilder,
) { ) {
this.form = this.ngFormBuilder.group({ this.form = this.ngFormBuilder.group({
currentPassword: [null, Validators.required], currentPassword: [null, Validators.required],
newPassword: [null, [Validators.required, Validators.minLength(8)]], newPassword: [null, [Validators.required, Validators.minLength(8)]],
verifyNewPassword: [null, [Validators.required, this.validatePassword()]], verifyNewPassword: [null, Validators.required],
}); }, { validators: this.passwordsMatchValidator });
this.form.get('newPassword')?.valueChanges.subscribe(() =>
this.form.get('verifyNewPassword')?.updateValueAndValidity({ onlySelf: true })
);
} }
private validatePassword(): ValidatorFn { private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
return (control: AbstractControl): ValidationErrors | null => { const newPwd = group.get('newPassword')?.value;
return typeof control.value === 'string' && control.value.length const verify = group.get('verifyNewPassword')?.value;
? control.value === control.parent?.value.newPassword return newPwd && verify && newPwd !== verify ? { passwordMismatch: true } : null;
? null : { invalid: true } : null;
}
} }
get currentPassword() { return this.form.get('currentPassword'); }
get newPassword() { return this.form.get('newPassword'); }
get verifyNewPassword() { return this.form.get('verifyNewPassword'); }
reset(): void { reset(): void {
this.form.reset(); this.form.reset();
} }
async save(): Promise<void> { async save(): Promise<void> {
const config: MatSnackBarConfig = { duration: 5000 }; if (this.form.invalid) {
const currentPassword = this.form.get('currentPassword')?.value; this.form.markAllAsTouched();
const newPassword = this.form.get('newPassword')?.value; return;
this.form.disable();
try {
if (currentPassword && newPassword) {
await this.adminService.changePassword(currentPassword, newPassword);
}
this.matSnackBar.open('Password changed successfully', '', config);
this.form.reset();
} catch (_) {
this.matSnackBar.open('Unable to change password', '', config);
} }
this.form.enable(); const config = { duration: 5000 };
const currentPassword = this.currentPassword?.value;
const newPassword = this.newPassword?.value;
this.form.disable();
try {
await this.adminService.changePassword(currentPassword, newPassword);
this.matSnackBar.open('Password changed successfully', '', config);
this.form.reset();
} catch {
this.matSnackBar.open('Unable to change password', '', config);
} finally {
this.form.enable();
}
} }
} }

View file

@ -24,6 +24,7 @@ import { Config } from '../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-tools', selector: 'rdio-scanner-admin-tools',
templateUrl: './tools.component.html', templateUrl: './tools.component.html',
standalone: false
}) })
export class RdioScannerAdminToolsComponent { export class RdioScannerAdminToolsComponent {
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();

View file

@ -1,177 +1,193 @@
<div class="rdio-status"> <div class="rdio-status">
<div class="branding">{{ branding }}</div> <div class="branding">{{ branding }}</div>
<div class="led" [ngClass]="ledStyle"></div> <div class="led" [ngClass]="ledStyle"></div>
</div> </div>
<div class="rdio-display" [ngClass]="{ idle: !dimmer }" (dblclick)="toggleFullscreen.emit()"> <div class="rdio-display" [ngClass]="{ idle: !dimmer }" (dblclick)="toggleFullscreen.emit()">
<div class="row three"> <div class="row three">
<div> <div>
<span>{{ clock | date:timeFormat }}</span> <span>{{ clock | date:timeFormat }}</span>
</div>
<div>
<span *ngIf="!linked">NO LINK</span>
<span *ngIf="linked && showListenersCount">L: {{ listeners }}</span>
</div>
<div>
<span>Q: {{ callQueue }}</span>
</div>
</div> </div>
<div class="row small"></div> <div>
<div class="row"> @if (!linked) {
<div> <span>NO LINK</span>
<span>{{ callSystem }}</span> }
</div> @if (linked && showListenersCount) {
<div> <span>L: {{ listeners }}</span>
<span>{{ type | uppercase }}</span> }
</div>
<div>
<span>{{ callTag }}</span>
</div>
</div> </div>
<div class="row"> <div>
<div> <span>Q: {{ callQueue }}</span>
<span>{{ callTalkgroup }}</span>
</div>
<div></div>
<div>
<span>{{ callDate | date:'MM/dd' }} </span>
<span>{{ callProgress | date:timeFormat }}</span>
</div>
</div> </div>
<div class="row big full"> </div>
<span>{{ callTalkgroupName }}</span> <div class="row small"></div>
<div class="row">
<div>
<span>{{ callSystem }}</span>
</div> </div>
<div class="row"> <div>
<div> <span>{{ type | uppercase }}</span>
<span>F: {{ callFrequency || 0 }}</span>
</div>
<div></div>
<div>
<span>TGID: {{ callTalkgroupId || 0 }}</span>
</div>
</div> </div>
<div class="row"> <div>
<div> <span>{{ callTag }}</span>
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }} </span>
</div>
<div></div>
<div>
<span *ngIf="callUnit">UID: {{ callUnit }}</span>
</div>
</div> </div>
<div class="row right small"> </div>
<div></div> <div class="row">
<div></div> <div>
<span>{{ callTalkgroup }}</span>
</div>
<div></div>
<div>
<span>{{ callDate | date:'MM/dd' }} </span>
<span>{{ callProgress | date:timeFormat }}</span>
</div>
</div>
<div class="row big full">
<span>{{ callTalkgroupName }}</span>
</div>
<div class="row">
<div>
<span>F: {{ callFrequency || 0 }}</span>
</div>
<div></div>
<div>
<span>TGID: {{ callTalkgroupId || 0 }}</span>
</div>
</div>
<div class="row">
<div>
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }} </span>
</div>
<div></div>
<div>
@if (callUnit) {
<span>UID: {{ callUnit }}</span>
}
</div>
</div>
<div class="row right small">
<div></div>
<div></div>
<div>
@if (tempAvoid) {
<div> <div>
<div *ngIf="tempAvoid"> <span class="flag" [ngClass]="{ flaged: avoided || patched }">&#x23f2;&#xFE0E; {{ tempAvoid }}M</span>
<span class="flag" [ngClass]="{ flaged: avoided || patched }">&#x23f2;&#xFE0E; {{ tempAvoid }}M</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: avoided }">AVOID</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: patched }">PATCH</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: delayed }">DELAYED</span>
</div>
</div> </div>
}
<div>
<span class="flag" [ngClass]="{ flaged: avoided }">AVOID</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: patched }">PATCH</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: delayed }">DELAYED</span>
</div>
</div> </div>
<div class="wrapper"> </div>
<div class="auth" [ngClass]="{ visible: auth }"> <div class="wrapper">
<form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()"> <div class="auth" [ngClass]="{ visible: auth }">
<mat-form-field> <form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()">
<input #password matInput type="password" autocomplete="off" formControlName="password" <mat-form-field>
placeholder="Unlock code" (blur)="authFocus()"> <input #password matInput type="password" autocomplete="off" formControlName="password"
<mat-error *ngIf="authForm.get('password')?.hasError('expired')"> placeholder="Unlock code" (blur)="authFocus()">
This unlock code has expired @if (authForm.get('password')?.hasError('expired')) {
</mat-error> <mat-error>
<mat-error *ngIf="authForm.get('password')?.hasError('tooMany')"> This unlock code has expired
Too many connections </mat-error>
</mat-error> }
</mat-form-field> @if (authForm.get('password')?.hasError('tooMany')) {
</form> <mat-error>
</div> Too many connections
<table class="history"> </mat-error>
<thead> }
<tr> </mat-form-field>
<th> </form>
<span>Time</span>
</th>
<th>
<span>System</span>
</th>
<th>
<span>Talkgroup</span>
</th>
<th>
<span>Name</span>
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let previousCall of callHistory"
[ngClass]="{ 'replaying': previousCall?.id !== null && previousCall?.id === call?.id} ">
<td>
<span>{{ previousCall?.dateTime | date:timeFormat }}</span>
</td>
<td>
<span>{{ previousCall?.systemData?.label || previousCall?.system}}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.label || previousCall?.talkgroup }}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span>
</td>
</tr>
</tbody>
</table>
</div> </div>
<table class="history">
<thead>
<tr>
<th>
<span>Time</span>
</th>
<th>
<span>System</span>
</th>
<th>
<span>Talkgroup</span>
</th>
<th>
<span>Name</span>
</th>
</tr>
</thead>
<tbody>
@for (previousCall of callHistory; track $index) {
<tr
[ngClass]="{ 'replaying': previousCall?.id !== null && previousCall?.id === call?.id} ">
<td>
<span>{{ previousCall?.dateTime | date:timeFormat }}</span>
</td>
<td>
<span>{{ previousCall?.systemData?.label || previousCall?.system}}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.label || previousCall?.talkgroup }}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div> </div>
<div class="rdio-control"> <div class="rdio-control">
<div class="row"> <div class="row">
<button class="rdio-button" (click)="livefeed()" <button class="rdio-button" (click)="livefeed()"
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }"> [ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
LIVE<br>FEED LIVE<br>FEED
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdSystem()" [ngClass]="{ off: !holdSys, on: holdSys }">
HOLD<br>SYS
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdTalkgroup()" [ngClass]="{ off: !holdTg, on: holdTg }">
HOLD<br>TG
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="replay()">
REPLAY<br>LAST
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="skip()">
SKIP<br>NEXT
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="avoid()">
AVOID
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="showSearchPanel()">
SEARCH<br>CALL
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="pause()" [ngClass]="{ off: !livefeedPaused, on: livefeedPaused }">
PAUSE
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="showSelectPanel()">
SELECT<br>TG
</button>
</div>
</div>
<div class="rdio-help" *ngIf="email">
<button mat-icon-button (click)="showHelp()">
<mat-icon>help_center</mat-icon>
</button> </button>
</div> <div class="spacer"></div>
<button class="rdio-button" (click)="holdSystem()" [ngClass]="{ off: !holdSys, on: holdSys }">
HOLD<br>SYS
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdTalkgroup()" [ngClass]="{ off: !holdTg, on: holdTg }">
HOLD<br>TG
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="replay()">
REPLAY<br>LAST
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="skip()">
SKIP<br>NEXT
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="avoid()">
AVOID
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="showSearchPanel()">
SEARCH<br>CALL
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="pause()" [ngClass]="{ off: !livefeedPaused, on: livefeedPaused }">
PAUSE
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="showSelectPanel()">
SELECT<br>TG
</button>
</div>
</div>
@if (email) {
<div class="rdio-help">
<button mat-icon-button (click)="showHelp()">
<mat-icon>help_center</mat-icon>
</button>
</div>
}

View file

@ -42,6 +42,7 @@ import { RdioScannerSupportComponent } from './support/support.component';
'./main.component.scss', './main.component.scss',
], ],
templateUrl: './main.component.html', templateUrl: './main.component.html',
standalone: false
}) })
export class RdioScannerMainComponent implements OnDestroy, OnInit { export class RdioScannerMainComponent implements OnDestroy, OnInit {
auth = false; auth = false;
@ -530,7 +531,7 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
} }
private isAfsSystem(call: RdioScannerCall): boolean { private isAfsSystem(call: RdioScannerCall): boolean {
return (call.systemData?.type == 'provoice' ?? false) || (call.talkgroupData?.type === 'provoice' ?? false); return (call.systemData?.type === 'provoice') || (call.talkgroupData?.type === 'provoice');
} }
private syncClock(): void { private syncClock(): void {

View file

@ -1,10 +1,10 @@
<span class="support"> <span class="support">
<ng-container *ngIf="email"> @if (email) {
For further assistance please contact <b><a href="mailto:{{ email }}" target="_blank">{{ email }}</a></b> For further assistance please contact <b><a href="mailto:{{ email }}" target="_blank">{{ email }}</a></b>
</ng-container> }
<ng-container *ngIf="!email"> @if (!email) {
For further assistance please go to <b><a href="https://github.com/chuot/rdio-scanner/discussions" For further assistance please go to <b><a href="https://github.com/chuot/rdio-scanner/discussions"
target="_blank">Rdio&nbsp;Scanner&nbsp;discussions</a></b> target="_blank">Rdio&nbsp;Scanner&nbsp;discussions</a></b>
</ng-container> }
</span> </span>
<span class="mat-caption countdown">{{ countdown }}</span> <span class="mat-caption countdown">{{ countdown }}</span>

View file

@ -25,6 +25,7 @@ import { timer } from 'rxjs';
selector: 'RdioScannerSupport', selector: 'RdioScannerSupport',
styleUrls: ['./support.component.scss'], styleUrls: ['./support.component.scss'],
templateUrl: './support.component.html', templateUrl: './support.component.html',
standalone: false
}) })
export class RdioScannerSupportComponent { export class RdioScannerSupportComponent {
countdown = 10; countdown = 10;

View file

@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'app-store-badge', selector: 'app-store-badge',
templateUrl: './app-store-badge.component.svg', templateUrl: './app-store-badge.component.svg',
standalone: false
}) })
export class AppStoreBadgeComponent {} export class AppStoreBadgeComponent {}

View file

@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'google-play-badge', selector: 'google-play-badge',
templateUrl: './google-play-badge.component.svg', templateUrl: './google-play-badge.component.svg',
standalone: false
}) })
export class GooglePlayBadgeComponent {} export class GooglePlayBadgeComponent {}

View file

@ -1,18 +1,18 @@
<div> <div>
<div> <div>
<p>Do you know that Rdio&nbsp;Scanner works natively on {{ deviceType }} devices?</p> <p>Do you know that Rdio&nbsp;Scanner works natively on {{ deviceType }} devices?</p>
</div> </div>
<div> <div>
<ng-container *ngIf="isAndroid"> @if (isAndroid) {
<a href="https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner" target="_blank"> <a href="https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner" target="_blank">
<google-play-badge></google-play-badge> <google-play-badge></google-play-badge>
</a> </a>
</ng-container> }
<ng-container *ngIf="isApple"> @if (isApple) {
<a href="https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone" target="_blank"> <a href="https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone" target="_blank">
<app-store-badge></app-store-badge> <app-store-badge></app-store-badge>
</a> </a>
</ng-container> }
</div> </div>
</div> </div>
<div class="countdown">{{ countdown }}</div> <div class="countdown">{{ countdown }}</div>

View file

@ -25,6 +25,7 @@ import { timer } from 'rxjs';
selector: 'RdioScannerNative', selector: 'RdioScannerNative',
styleUrls: ['./native.component.scss'], styleUrls: ['./native.component.scss'],
templateUrl: './native.component.html', templateUrl: './native.component.html',
standalone: false
}) })
export class RdioScannerNativeComponent implements OnInit { export class RdioScannerNativeComponent implements OnInit {
countdown = 10; countdown = 10;

View file

@ -29,6 +29,7 @@ import { RdioScannerNativeComponent } from './native/native.component';
selector: 'rdio-scanner', selector: 'rdio-scanner',
styleUrls: ['./rdio-scanner.component.scss'], styleUrls: ['./rdio-scanner.component.scss'],
templateUrl: './rdio-scanner.component.html', templateUrl: './rdio-scanner.component.html',
standalone: false
}) })
export class RdioScannerComponent implements OnDestroy, OnInit { export class RdioScannerComponent implements OnDestroy, OnInit {
private eventSubscription; private eventSubscription;

View file

@ -17,8 +17,8 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { DOCUMENT } from '@angular/common';
import { EventEmitter, Inject, Injectable, OnDestroy } from '@angular/core'; import { EventEmitter, Inject, Injectable, OnDestroy, DOCUMENT } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { interval, Subscription, timer } from 'rxjs'; import { interval, Subscription, timer } from 'rxjs';
import { takeWhile } from 'rxjs/operators'; import { takeWhile } from 'rxjs/operators';

View file

@ -1,165 +1,181 @@
<mat-card appearance="outlined" class="rdio-list"> <mat-card appearance="outlined" class="rdio-list">
<mat-table [dataSource]="results"> <mat-table [dataSource]="results">
<ng-container matColumnDef="control"> <ng-container matColumnDef="control">
<mat-header-cell *matHeaderCellDef></mat-header-cell> <mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row"> <mat-cell *matCellDef="let row">
<button *ngIf="downloadMode.checked && row" mat-icon-button (click)="download(+row.id)"> @if (downloadMode.checked && row) {
<mat-icon>save_alt</mat-icon> <button mat-icon-button (click)="download(+row.id)">
</button>
<button *ngIf="row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending"
mat-icon-button (click)="play(+row.id)">
<mat-icon>play_arrow</mat-icon>
</button>
<button *ngIf="row && !downloadMode.checked && row?.id == callPending" mat-icon-button>
<mat-icon class="spin">cached</mat-icon>
</button>
<button *ngIf="row && !downloadMode.checked && row?.id == call?.id" mat-icon-button (click)="stop()">
<mat-icon>stop</mat-icon>
</button>
</mat-cell>
</ng-container>
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>
<span>Date</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.dateTime | date:'MM/dd' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef [ngClass]="{'time12h': time12h}">
<span>Time</span>
</mat-header-cell>
<mat-cell *matCellDef="let row" [ngClass]="{'time12h': time12h}">
<span>{{ row?.dateTime | date: time12h ? 'h:mm a' : 'HH:mm' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="system">
<mat-header-cell *matHeaderCellDef>
<span>System</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.systemData?.label || row?.system }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="alpha">
<mat-header-cell *matHeaderCellDef>
<span>Talkgroup</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.label || row?.talkgroup }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>
<span>Name</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.name }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-row>
</mat-table>
<mat-progress-bar color="primary" [mode]="resultsPending ? 'query' : 'determinate'">
</mat-progress-bar>
<div class="paginator">
<mat-slide-toggle #downloadMode color="primary" labelPosition="before">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
</mat-slide-toggle> </button>
<mat-paginator [disabled]="livefeedPlayback || resultsPending" [length]="playbackList?.count" }
[hidePageSize]="true" [pageSize]="results.value.length" [showFirstLastButtons]="true" @if (row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending) {
(page)="refreshResults()"> <button
</mat-paginator> mat-icon-button (click)="play(+row.id)">
</div> <mat-icon>play_arrow</mat-icon>
</button>
}
@if (row && !downloadMode.checked && row?.id == callPending) {
<button mat-icon-button>
<mat-icon class="spin">cached</mat-icon>
</button>
}
@if (row && !downloadMode.checked && row?.id == call?.id) {
<button mat-icon-button (click)="stop()">
<mat-icon>stop</mat-icon>
</button>
}
</mat-cell>
</ng-container>
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>
<span>Date</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.dateTime | date:'MM/dd' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef [ngClass]="{'time12h': time12h}">
<span>Time</span>
</mat-header-cell>
<mat-cell *matCellDef="let row" [ngClass]="{'time12h': time12h}">
<span>{{ row?.dateTime | date: time12h ? 'h:mm a' : 'HH:mm' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="system">
<mat-header-cell *matHeaderCellDef>
<span>System</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.systemData?.label || row?.system }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="alpha">
<mat-header-cell *matHeaderCellDef>
<span>Talkgroup</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.label || row?.talkgroup }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>
<span>Name</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.name }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-row>
</mat-table>
<mat-progress-bar color="primary" [mode]="resultsPending ? 'query' : 'determinate'">
</mat-progress-bar>
<div class="paginator">
<mat-slide-toggle #downloadMode color="primary" labelPosition="before">
<mat-icon>save_alt</mat-icon>
</mat-slide-toggle>
<mat-paginator [disabled]="livefeedPlayback || resultsPending" [length]="playbackList?.count"
[hidePageSize]="true" [pageSize]="results.value.length" [showFirstLastButtons]="true"
(page)="refreshResults()">
</mat-paginator>
</div>
</mat-card> </mat-card>
<mat-card appearance="outlined" class="rdio-filters"> <mat-card appearance="outlined" class="rdio-filters">
<form autocomplete="off" [formGroup]="form"> <form autocomplete="off" [formGroup]="form">
<mat-form-field> <mat-form-field>
<mat-label> <mat-label>
Sort order Sort order
</mat-label> </mat-label>
<mat-select formControlName="sort" (selectionChange)="formChangeHandler()"> <mat-select formControlName="sort" (selectionChange)="formChangeHandler()">
<mat-option [value]="1"> <mat-option [value]="1">
Ascending Ascending
</mat-option> </mat-option>
<mat-option [value]="-1"> <mat-option [value]="-1">
Descending Descending
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label> <mat-label>
Date Date
</mat-label> </mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop" <input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop"
[min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()"> [min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()">
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label> <mat-label>
System System
</mat-label> </mat-label>
<mat-select formControlName="system" (selectionChange)="formChangeHandler()"> <mat-select formControlName="system" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1"> <mat-option [value]="-1">
All Systems All Systems
</mat-option> </mat-option>
<mat-option *ngFor="let system of optionsSystem; index as i" [value]="i"> @for (system of optionsSystem; track system; let i = $index) {
{{ system }} <mat-option [value]="i">
</mat-option> {{ system }}
</mat-select> </mat-option>
</mat-form-field> }
<mat-form-field> </mat-select>
<mat-label> </mat-form-field>
Talkgroup <mat-form-field>
</mat-label> <mat-label>
<mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()"> Talkgroup
<mat-option [value]="-1"> </mat-label>
All Talkgroups <mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()">
</mat-option> <mat-option [value]="-1">
<mat-option *ngFor="let talkgroup of optionsTalkgroup; index as i" [value]="i"> All Talkgroups
{{ talkgroup }} </mat-option>
</mat-option> @for (talkgroup of optionsTalkgroup; track talkgroup; let i = $index) {
</mat-select> <mat-option [value]="i">
</mat-form-field> {{ talkgroup }}
<mat-form-field> </mat-option>
<mat-label> }
Group </mat-select>
</mat-label> </mat-form-field>
<mat-select formControlName="group" (selectionChange)="formChangeHandler()"> <mat-form-field>
<mat-option [value]="-1"> <mat-label>
All Groups Group
</mat-option> </mat-label>
<mat-option *ngFor="let group of optionsGroup; index as i" [value]="i"> <mat-select formControlName="group" (selectionChange)="formChangeHandler()">
{{ group }} <mat-option [value]="-1">
</mat-option> All Groups
</mat-select> </mat-option>
</mat-form-field> @for (group of optionsGroup; track group; let i = $index) {
<mat-form-field> <mat-option [value]="i">
<mat-label> {{ group }}
Tag </mat-option>
</mat-label> }
<mat-select formControlName="tag" (selectionChange)="formChangeHandler()"> </mat-select>
<mat-option [value]="-1"> </mat-form-field>
All Tags <mat-form-field>
</mat-option> <mat-label>
<mat-option *ngFor="let tag of optionsTag; index as i" [value]="i"> Tag
{{ tag }} </mat-label>
</mat-option> <mat-select formControlName="tag" (selectionChange)="formChangeHandler()">
</mat-select> <mat-option [value]="-1">
</mat-form-field> All Tags
<mat-form-field> </mat-option>
<mat-label> @for (tag of optionsTag; track tag; let i = $index) {
Unit <mat-option [value]="i">
</mat-label> {{ tag }}
<input type="number" matInput> </mat-option>
<mat-icon matSuffix>search</mat-icon> }
</mat-form-field> </mat-select>
<div class="reset"> </mat-form-field>
<button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()"> <mat-form-field>
Reset <mat-label>
</button> Unit
</div> </mat-label>
</form> <input type="number" matInput>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<div class="reset">
<button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()">
Reset
</button>
</div>
</form>
</mat-card> </mat-card>

View file

@ -37,6 +37,7 @@ import { RdioScannerService } from '../rdio-scanner.service';
selector: 'rdio-scanner-search', selector: 'rdio-scanner-search',
styleUrls: ['./search.component.scss'], styleUrls: ['./search.component.scss'],
templateUrl: './search.component.html', templateUrl: './search.component.html',
standalone: false
}) })
export class RdioScannerSearchComponent implements OnDestroy { export class RdioScannerSearchComponent implements OnDestroy {
call: RdioScannerCall | undefined; call: RdioScannerCall | undefined;

View file

@ -1,39 +1,47 @@
<fieldset *ngIf="categories?.length && (categories?.length || 0) >= 2" class="fieldset"> @if (categories?.length && (categories?.length || 0) >= 2) {
<fieldset class="fieldset">
<div> <div>
<button *ngFor="let category of categories" class="rdio-button" [ngClass]="category.status" @for (category of categories; track category) {
(click)="toggle(category)"> <button class="rdio-button" [ngClass]="category.status"
{{ category.label }} (click)="toggle(category)">
</button> {{ category.label }}
<button class="rdio-button-mini all-off" (click)="avoid({ all: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ all: true })">
ON
</button> </button>
}
<button class="rdio-button-mini all-off" (click)="avoid({ all: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ all: true })">
ON
</button>
</div> </div>
</fieldset> </fieldset>
<ng-container *ngFor="let system of systems"> }
<fieldset *ngIf="system.talkgroups.length" class="fieldset"> @for (system of systems; track system) {
<legend> @if (system.talkgroups.length) {
{{ system.label }} <fieldset class="fieldset">
</legend> <legend>
<div> {{ system.label }}
<button *ngFor="let talkgroup of system.talkgroups" class="rdio-button" </legend>
[ngClass]="{ off: !(map[system.id] && map[system.id][talkgroup.id]).active, on: map[system.id] && map[system.id][talkgroup.id].active, blink: map[system.id][talkgroup.id].minutes }" <div>
(click)="avoid({ system: system, talkgroup: talkgroup })"> @for (talkgroup of system.talkgroups; track talkgroup) {
{{ talkgroup.label }} <button class="rdio-button"
</button> [ngClass]="{ off: !(map[system.id] && map[system.id][talkgroup.id]).active, on: map[system.id] && map[system.id][talkgroup.id].active, blink: map[system.id][talkgroup.id].minutes }"
<ng-container *ngIf="system.talkgroups.length > 1"> (click)="avoid({ system: system, talkgroup: talkgroup })">
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })"> {{ talkgroup.label }}
OFF </button>
</button> }
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })"> @if (system.talkgroups.length > 1) {
ON <button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
</button> OFF
</ng-container> </button>
</div> <button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON
</button>
}
</div>
</fieldset> </fieldset>
</ng-container> }
<ng-container *ngIf="systems?.length === 0"> }
No talkgroup configured! @if (systems?.length === 0) {
</ng-container> No talkgroup configured!
}

View file

@ -36,6 +36,7 @@ import { RdioScannerService } from '../rdio-scanner.service';
'./select.component.scss', './select.component.scss',
], ],
templateUrl: './select.component.html', templateUrl: './select.component.html',
standalone: false
}) })
export class RdioScannerSelectComponent implements OnDestroy { export class RdioScannerSelectComponent implements OnDestroy {
categories: RdioScannerCategory[] | undefined; categories: RdioScannerCategory[] | undefined;

View file

@ -24,6 +24,7 @@ import packageInfo from '../../../../../package.json';
selector: 'rdio-scanner-admin-page', selector: 'rdio-scanner-admin-page',
styleUrls: ['./admin.component.scss'], styleUrls: ['./admin.component.scss'],
templateUrl: './admin.component.html', templateUrl: './admin.component.html',
standalone: false
}) })
export class RdioScannerAdminPageComponent { export class RdioScannerAdminPageComponent {
version = packageInfo.version; version = packageInfo.version;

View file

@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'rdio-scanner-main-page', selector: 'rdio-scanner-main-page',
template: '<rdio-scanner></rdio-scanner>', template: '<rdio-scanner></rdio-scanner>',
standalone: false
}) })
export class RdioScannerMainPageComponent { } export class RdioScannerMainPageComponent { }

View file

@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'rdio-scanner-page', selector: 'rdio-scanner-page',
template: '<router-outlet></router-outlet>', template: '<router-outlet></router-outlet>',
standalone: false
}) })
export class RdioScannerPageComponent { } export class RdioScannerPageComponent { }

View file

@ -20,7 +20,8 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
@Component({ @Component({
templateUrl: './update.component.html', templateUrl: './update.component.html',
styleUrls: ['./update.component.scss'], styleUrls: ['./update.component.scss'],
standalone: false
}) })
export class AppUpdateComponent { } export class AppUpdateComponent { }

View file

@ -17,8 +17,8 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { enableProdMode } from '@angular/core'; import { enableProdMode, provideZoneChangeDetection } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
@ -27,4 +27,4 @@ if (environment.production) {
enableProdMode(); enableProdMode();
} }
platformBrowserDynamic().bootstrapModule(AppModule).catch((err) => console.error(err)); platformBrowser().bootstrapModule(AppModule, { applicationProviders: [provideZoneChangeDetection()], }).catch((err) => console.error(err));

View file

@ -1,20 +1,21 @@
@use "@angular/material" as mat; @use "@angular/material" as mat;
@import "./styles/material-icons"; @use "./styles/material-icons" as *;
@import "./styles/roboto-fonts"; @use "./styles/roboto-fonts" as *;
@include mat.core(); @include mat.elevation-classes();
@include mat.app-background();
$rdio-scanner-font-family: Roboto, "Helvetica Neue", sans-serif; $rdio-scanner-font-family: Roboto, "Helvetica Neue", sans-serif;
$rdio-scanner-primary: mat.define-palette(mat.$grey-palette, 800, 50); $rdio-scanner-primary: mat.m2-define-palette(mat.$m2-grey-palette, 800, 50);
$rdio-scanner-accent: mat.define-palette(mat.$green-palette); $rdio-scanner-accent: mat.m2-define-palette(mat.$m2-green-palette);
$rdio-scanner-warn: mat.define-palette(mat.$red-palette); $rdio-scanner-warn: mat.m2-define-palette(mat.$m2-red-palette);
$rdio-scanner-typography: mat.define-typography-config( $rdio-scanner-typography: mat.m2-define-typography-config(
$font-family: $rdio-scanner-font-family, $font-family: $rdio-scanner-font-family,
); );
$rdio-scanner-theme: mat.define-light-theme( $rdio-scanner-theme: mat.m2-define-light-theme(
( (
color: ( color: (
primary: $rdio-scanner-primary, primary: $rdio-scanner-primary,

View file

@ -5,24 +5,19 @@
"outDir": "./dist/out-tsc", "outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"esModuleInterop": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"sourceMap": true, "sourceMap": true,
"declaration": false, "declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"moduleResolution": "node", "moduleResolution": "bundler",
"importHelpers": true, "importHelpers": true,
"target": "es2022", "target": "es2022",
"module": "es2022", "module": "es2022",
"lib": [ "resolveJsonModule": true
"es2022",
"dom"
],
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
}, },
"angularCompilerOptions": { "angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,