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": "",
"sourceRoot": "src",
"prefix": "rdio-scanner",
"architect": {
"targets": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "../server/webapp",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
@ -36,15 +37,16 @@
],
"scripts": [],
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
"ngswConfigPath": "ngsw-config.json",
"main": "src/main.ts"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1500kb",
"maximumError": "1500kb"
"maximumWarning": "2mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
@ -61,9 +63,7 @@
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
@ -75,11 +75,11 @@
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "rdio-scanner:build:production"
"buildTarget": "rdio-scanner:build:production"
},
"development": {
"browserTarget": "rdio-scanner:build:development",
"proxyConfig": "src/proxy.conf.js"
"proxyConfig": "src/proxy.conf.js",
"buildTarget": "rdio-scanner:build:development"
}
},
"defaultConfiguration": "development"
@ -87,14 +87,16 @@
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "rdio-scanner:build"
"buildTarget": "rdio-scanner:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
@ -114,5 +116,31 @@
},
"cli": {
"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": {
"@angular/animations": "^15.0.4",
"@angular/cdk": "^15.0.3",
"@angular/common": "^15.0.4",
"@angular/compiler": "^15.0.4",
"@angular/core": "^15.0.4",
"@angular/forms": "^15.0.4",
"@angular/material": "^15.0.3",
"@angular/platform-browser": "^15.0.4",
"@angular/platform-browser-dynamic": "^15.0.4",
"@angular/router": "^15.0.4",
"@angular/service-worker": "^15.0.4",
"rxjs": "~7.5.0",
"tslib": "^2.3.0",
"zone.js": "~0.11.4"
"@angular/animations": "^21.0.6",
"@angular/cdk": "^21.0.5",
"@angular/common": "^21.0.6",
"@angular/compiler": "^21.0.6",
"@angular/core": "^21.0.6",
"@angular/forms": "^21.0.6",
"@angular/material": "^21.0.5",
"@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "^21.0.6",
"@angular/router": "^21.0.6",
"@angular/service-worker": "^21.0.6",
"material-icons": "^1.13.14",
"rxjs": "~7.8.2",
"tslib": "^2.8.1",
"zone.js": "~0.16.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^15.0.4",
"@angular/cli": "^15.0.4",
"@angular/compiler-cli": "^15.0.4",
"@types/node": "^18.11.2",
"@typescript-eslint/eslint-plugin": "^5.40.1",
"@typescript-eslint/parser": "^5.40.1",
"@angular-devkit/build-angular": "^21.0.4",
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.6",
"@types/node": "^25.0.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"ajv-keywords": "^5.1.0",
"eslint": "^8.25.0",
"ts-node": "~10.9.1",
"typescript": "~4.8.4"
"eslint": "^9.39.2",
"ts-node": "~10.9.2",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=16.0"
@ -36,7 +38,7 @@
"private": true,
"scripts": {
"build": "ng build --base-href ./ --configuration production",
"start": "ng serve"
"serve": "ng serve"
},
"version": "7.0.0"
}

View file

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

View file

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

View file

@ -25,6 +25,7 @@ import { AdminEvent, RdioScannerAdminService, Group, Tag } from './admin.service
selector: 'rdio-scanner-admin',
styleUrls: ['./admin.component.scss'],
templateUrl: './admin.component.html',
standalone: false
})
export class RdioScannerAdminComponent implements OnDestroy {
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 { AppSharedModule } from '../../../shared/shared.module';
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 { RdioScannerAdminPasswordComponent } from './tools/password/password.component';
@NgModule({
declarations: [
@NgModule({ declarations: [
RdioScannerAdminComponent,
RdioScannerAdminConfigComponent,
RdioScannerAdminAccessComponent,
@ -71,9 +70,5 @@ import { RdioScannerAdminPasswordComponent } from './tools/password/password.com
RdioScannerAdminToolsComponent,
RdioScannerAdminUnitComponent,
],
entryComponents: [RdioScannerAdminSystemsSelectComponent],
exports: [RdioScannerAdminComponent],
imports: [AppSharedModule, HttpClientModule],
providers: [RdioScannerAdminService],
})
exports: [RdioScannerAdminComponent], imports: [AppSharedModule], providers: [RdioScannerAdminService, provideHttpClient(withInterceptorsFromDi())] })
export class RdioScannerAdminModule { }

View file

@ -1,95 +1,111 @@
<div class="row top">
<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
internally on the client side and reused thereafter.</p>
<button type="button" mat-button color="accent" (click)="add()">New access</button>
<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
internally on the client side and reused thereafter.</p>
<button type="button" mat-button color="accent" (click)="add()">New access</button>
</div>
<p *ngIf="!accesses.length" class="mat-small text-center">No access is defined, your instance is open and accessible to
all</p>
@if (!accesses.length) {
<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-expansion-panel *ngFor="let access of accesses; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ access.value.ident || 'NewAccess' }}
<mat-icon *ngIf="access.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="access">
<div class="row">
<p>
<span class="mat-body">Code</span><br>
<span class="mat-caption">The access code to unlock the client.</span>
</p>
<mat-form-field floatLabel="auto">
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
<mat-error *ngIf="access.get('code')?.hasError('duplicate')">
Code is already defined
</mat-error>
<mat-error *ngIf="access.get('code')?.hasError('required')">
Code is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for
whom.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="access.get('ident')?.hasError('required')">
Ident is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Expiration</span><br>
<span class="mat-caption">Expiration date for this access code.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="expiration" placeholder="Expiration date"
[matDatepicker]="expirationDate" (click)="expirationDate.open()">
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
<mat-datepicker #expirationDate></mat-datepicker>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Limit</span><br>
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
with this access code exceeds the defined limit.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
The access code allows access to <u>
<ng-container *ngIf="access.value.systems === '*'">all</ng-container>
<ng-container *ngIf="access.value.systems !== '*'">some</ng-container>
</u> systems and talkgroups.
</span>
</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>
@for (access of accesses; track access; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ access.value.ident || 'NewAccess' }}
@if (access.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="access">
<div class="row">
<p>
<span class="mat-body">Code</span><br>
<span class="mat-caption">The access code to unlock the client.</span>
</p>
<mat-form-field floatLabel="auto">
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (access.get('code')?.hasError('duplicate')) {
<mat-error>
Code is already defined
</mat-error>
}
@if (access.get('code')?.hasError('required')) {
<mat-error>
Code is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for
whom.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="ident" placeholder="Ident">
@if (access.get('ident')?.hasError('required')) {
<mat-error>
Ident is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Expiration</span><br>
<span class="mat-caption">Expiration date for this access code.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="expiration" placeholder="Expiration date"
[matDatepicker]="expirationDate" (click)="expirationDate.open()">
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
<mat-datepicker #expirationDate></mat-datepicker>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Limit</span><br>
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
with this access code exceeds the defined limit.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
The access code allows access to <u>
@if (access.value.systems === '*') {
all
}
@if (access.value.systems !== '*') {
some
}
</u> systems and talkgroups.
</span>
</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>

View file

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

View file

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

View file

@ -1,80 +1,96 @@
<div class="row top">
<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>
<button type="button" mat-button color="accent" (click)="add()">New API key</button>
<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>
<button type="button" mat-button color="accent" (click)="add()">New API key</button>
</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-expansion-panel *ngFor="let apikey of apikeys; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ apikey.value.ident || 'NewApikey' }}
<mat-icon *ngIf="apikey.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="apikey">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the API key.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Key</span><br>
<span class="mat-caption">Api key.</span>
</p>
<mat-form-field floatLabel="auto">
<input #key type="text" matInput formControlName="key" placeholder="Key">
<button type="button" mat-icon-button matSuffix (click)="copy(key)">
<mat-icon>content_copy</mat-icon>
</button>
<mat-error *ngIf="apikey.get('key')?.hasError('duplicate')">
Key is already defined
</mat-error>
<mat-error *ngIf="apikey.get('key')?.hasError('required')">
Key is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this API key is for
whom.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="apikey.get('ident')?.hasError('required')">
Ident is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This API key allows access to <u>
<ng-container *ngIf="apikey.value.systems === '*'">all</ng-container>
<ng-container *ngIf="apikey.value.systems !== '*'">some</ng-container>
</u> systems and talkgroups.
</span>
</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>
@for (apikey of apikeys; track apikey; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ apikey.value.ident || 'NewApikey' }}
@if (apikey.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="apikey">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the API key.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Key</span><br>
<span class="mat-caption">Api key.</span>
</p>
<mat-form-field floatLabel="auto">
<input #key type="text" matInput formControlName="key" placeholder="Key">
<button type="button" mat-icon-button matSuffix (click)="copy(key)">
<mat-icon>content_copy</mat-icon>
</button>
@if (apikey.get('key')!.hasError('duplicate')) {
<mat-error>
Key is already defined
</mat-error>
}
@if (apikey.get('key')!.hasError('required')) {
<mat-error>
Key is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this API key is for
whom.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="ident" placeholder="Ident">
@if (apikey.get('ident')!.hasError('required')) {
<mat-error>
Ident is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This API key allows access to <u>
@if (apikey.value.systems === '*') {
all
}
@if (apikey.value.systems !== '*') {
some
}
</u> systems and talkgroups.
</span>
</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>

View file

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

View file

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

View file

@ -21,6 +21,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -28,6 +30,7 @@ import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
selector: 'rdio-scanner-admin-config',
styleUrls: ['./config.component.scss'],
templateUrl: './config.component.html',
standalone: false
})
export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
docker = false;
@ -68,7 +71,7 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
private config: Config | undefined;
private eventSubscription;
private readonly destroy$ = new Subject<void>();
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
@ -76,10 +79,9 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
private adminService: RdioScannerAdminService,
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) {
this.config = await this.adminService.getConfig();
this.reset();
}
@ -98,7 +100,8 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
}
ngOnDestroy(): void {
this.eventSubscription.unsubscribe();
this.destroy$.next();
this.destroy$.complete();
}
async ngOnInit(): Promise<void> {
@ -116,12 +119,17 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
reset(config = this.config, options?: { dirty?: boolean }): void {
this.form = this.adminService.newConfigForm(config);
this.form.statusChanges.subscribe(() => {
this.form.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.ngChangeDetectorRef.markForCheck();
});
this.groups.valueChanges.subscribe(() => {
this.systems.controls.forEach((system) => {
const systemsArray = this.systems;
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;
talkgroups.controls.forEach((talkgroup) => {
@ -136,8 +144,9 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
});
});
this.tags.valueChanges.subscribe(() => {
this.systems.controls.forEach((system) => {
tagsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (!systemsArray) { return; }
systemsArray.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => {
@ -160,8 +169,10 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
}
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">
<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>
<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>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="dirwatches"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let dirwatch of dirwatches; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ dirwatch.value.directory || 'NewDirwatch' }}
<mat-icon *ngIf="dirwatch.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="dirwatch">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the dirwatch.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Delete After</span><br>
<span class="mat-caption">Delete the audio file after being ingested. If activated, all pre-existing
audio files will be ingested and deleted as soon as the server starts. If not activated,
pre-existing audio files are neither ingested nor deleted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Dirwatch type defines how the metadata are obtained.
<ul>
<li><b>Default</b> - Extract the metadata from a custom mask.</li>
<li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li>
<li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's
aliases tab.</li>
<li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="default">Default</mat-option>
<mat-option value="dsdplus">DSDPlus Fast Lane</mat-option>
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Directory</span><br>
<span class="mat-caption">
Path of the
<ng-container [ngSwitch]="dirwatch.get('type')?.value">
<ng-container *ngSwitchCase="'dsdplus'">
<b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b>
</ng-container>
<ng-container *ngSwitchCase="'sdr-trunk'">
<b>Recordings</b>
</ng-container>
<ng-container *ngSwitchCase="'trunk-recorder'">
<b>captureDir</b>
</ng-container>
<ng-container *ngSwitchDefault>
local
</ng-container>
</ng-container>
directory to monitor for file ingestion. Note that dirwatch is not compatible with networked
disk.
</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="directory" placeholder="Directory">
<mat-error *ngIf="dirwatch.get('directory')?.hasError('required')">
Directory is required
</mat-error>
<mat-error *ngIf="dirwatch.get('directory')?.hasError('duplicate')">
Directory is already defined
</mat-error>
<mat-error *ngIf="dirwatch.get('directory')?.hasError('network')">
Incompatible network folder
</mat-error>
</mat-form-field>
</div>
<div class="row" *ngIf="['default','dsdplus','trunk-recorder'].includes(dirwatch.get('type')?.value)">
<p>
<span class="mat-body">Extension</span><br>
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
"wav".</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="extension" placeholder="Extension">
<mat-error *ngIf="dirwatch.get('extension')?.hasError('invalid')">
Invalid extension
</mat-error>
</mat-form-field>
</div>
<div class="row" *ngIf="['default','dsdplus'].includes(dirwatch.get('type')?.value)">
<p>
<span class="mat-body">System</span><br>
<span class="mat-caption">System to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select #sysId formControlName="systemId" placeholder="System">
<mat-option [value]="null"></mat-option>
<ng-container *ngFor="let system of systems">
<mat-option *ngIf="system.value.id > 0" [value]="system.value.id">
{{ system.value.label }}
</mat-option>
</ng-container>
</mat-select>
<mat-error *ngIf="dirwatch.get('systemId')?.hasError('required')">
System is required
</mat-error>
</mat-form-field>
</div>
<div class="row" *ngIf="['default','dsdplus'].includes(dirwatch.get('type')?.value)">
<p>
<span class="mat-body">Site</span><br>
<span class="mat-caption">Site to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="siteId" placeholder="Site">
<mat-option [value]="null"></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-option>
</ng-container>
</mat-select>
</mat-form-field>
</div>
<div class="row" *ngIf="['default','dsdplus'].includes(dirwatch.get('type')?.value)">
<p>
<span class="mat-body">Talkgroup</span><br>
<span class="mat-caption">Talkgroup to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="talkgroupId" placeholder="Talkgroup">
<mat-option [value]="null"></mat-option>
<ng-container *ngFor="let talkgroup of talkgroups[dirwatch.value.systemId] || []">
<mat-option *ngIf="talkgroup.value.id > 0" [value]="talkgroup.value.id">
{{ talkgroup.value.label }}
</mat-option>
</ng-container>
</mat-select>
<mat-error *ngIf="dirwatch.get('talkgroupId')?.hasError('required')">
Talkgroup is required
</mat-error>
</mat-form-field>
</div>
<div class="row" *ngIf="['default'].includes(dirwatch.get('type')?.value)">
<p>
<span class="mat-body">Mask</span><br>
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
specific META tags. Possible META tags:
<ul>
<li><b>#DATE</b> - extract the date like 20201231&nbsp;(YYYYMMMDD),
2020-12-31&nbsp;(YYYY-MM-DD) or 2020_12_31&nbsp;(YYYY_MM_DD).
</li>
<li><b>#GROUP</b> - extract the group label.</li>
<li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li>
<li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li>
<li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li>
<li><b>#SITE</b> - extract the site id like 1.</li>
<li><b>#SITELBL</b> - extract the site label.</li>
<li><b>#SYS</b> - extract the system id like 11.</li>
<li><b>#SYSLBL</b> - extract the system label.</li>
<li><b>#TAG</b> - extract the tag label.</li>
<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>
<li><b>#TGHZ</b> - extract the frequency in hertz like 119100000 and set the talkgroup id to
119100.</li>
<li><b>#TGKHZ</b> - extract the frequency in kilohertz like 119100.000 or 119100 and set the
talkgroup id to 119100.</li>
<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
talkgroup id to 119100.</li>
<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>
<li><b>#UNIT</b> - extract the unit id like 4424001.</li>
<li><b>#UNITLBL</b> - extract the unit label. Also requires #UNIT.</li>
<li><b>#ZTIME</b> - extract the zulu time like 0453439&nbsp;(HHMMSS),
08-34-39&nbsp;(HH-MM-SS) or 04:34:39&nbsp;(HH:MM:SS).</li>
</ul>
Example: cymx_#TG_#DATE_#TIME_#HZ
</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="mask" placeholder="Mask">
<mat-error *ngIf="dirwatch.get('mask')?.hasError('invalid')">
Invalid mask
</mat-error>
</mat-form-field>
</div>
<div class="row" *ngIf="['default'].includes(dirwatch.get('type')?.value)">
<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">
<mat-error *ngIf="dirwatch.get('frequency')?.errors">
Invalid frequency
</mat-error>
</mat-form-field>
</div>
<div class="row" *ngIf="['default'].includes(dirwatch.get('type')?.value)">
<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">
<mat-error *ngIf="dirwatch.get('delay')?.hasError('min')">
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>
(cdkDropListDropped)="drop($event)">
@for (dirwatch of dirwatches; track dirwatch; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ dirwatch.value.directory || 'NewDirwatch' }}
@if (dirwatch.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="dirwatch">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the dirwatch.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Delete After</span><br>
<span class="mat-caption">Delete the audio file after being ingested. If activated, all pre-existing
audio files will be ingested and deleted as soon as the server starts. If not activated,
pre-existing audio files are neither ingested nor deleted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Dirwatch type defines how the metadata are obtained.
<ul>
<li><b>Default</b> - Extract the metadata from a custom mask.</li>
<li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li>
<li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's
aliases tab.</li>
<li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="default">Default</mat-option>
<mat-option value="dsdplus">DSDPlus Fast Lane</mat-option>
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Directory</span><br>
<span class="mat-caption">
Path of the
@switch (dirwatch.get('type')?.value) {
@case ('dsdplus') {
<b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b>
}
@case ('sdr-trunk') {
<b>Recordings</b>
}
@case ('trunk-recorder') {
<b>captureDir</b>
}
@default {
local
}
}
directory to monitor for file ingestion. Note that dirwatch is not compatible with networked
disk.
</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="directory" placeholder="Directory">
@if (dirwatch.get('directory')?.hasError('required')) {
<mat-error>
Directory is required
</mat-error>
}
@if (dirwatch.get('directory')?.hasError('duplicate')) {
<mat-error>
Directory is already defined
</mat-error>
}
@if (dirwatch.get('directory')?.hasError('network')) {
<mat-error>
Incompatible network folder
</mat-error>
}
</mat-form-field>
</div>
@if (['default','dsdplus','trunk-recorder'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Extension</span><br>
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
"wav".</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="extension" placeholder="Extension">
@if (dirwatch.get('extension')?.hasError('invalid')) {
<mat-error>
Invalid extension
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">System</span><br>
<span class="mat-caption">System to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select #sysId formControlName="systemId" placeholder="System">
<mat-option [value]="null"></mat-option>
@for (system of systems; track system) {
@if (system.value.id > 0) {
<mat-option [value]="system.value.id">
{{ system.value.label }}
</mat-option>
}
}
</mat-select>
@if (dirwatch.get('systemId')?.hasError('required')) {
<mat-error>
System is required
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Site</span><br>
<span class="mat-caption">Site to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="siteId" placeholder="Site">
<mat-option [value]="null"></mat-option>
@for (site of sites[dirwatch.value.systemId] || []; track site) {
@if (site.value.id > 0) {
<mat-option [value]="site.value.id">
{{ site.value.label }}
</mat-option>
}
}
</mat-select>
</mat-form-field>
</div>
}
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Talkgroup</span><br>
<span class="mat-caption">Talkgroup to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="talkgroupId" placeholder="Talkgroup">
<mat-option [value]="null"></mat-option>
@for (talkgroup of talkgroups[dirwatch.value.systemId] || []; track talkgroup) {
@if (talkgroup.value.id > 0) {
<mat-option [value]="talkgroup.value.id">
{{ talkgroup.value.label }}
</mat-option>
}
}
</mat-select>
@if (dirwatch.get('talkgroupId')?.hasError('required')) {
<mat-error>
Talkgroup is required
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Mask</span><br>
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
specific META tags. Possible META tags:
<ul>
<li><b>#DATE</b> - extract the date like 20201231&nbsp;(YYYYMMMDD),
2020-12-31&nbsp;(YYYY-MM-DD) or 2020_12_31&nbsp;(YYYY_MM_DD).
</li>
<li><b>#GROUP</b> - extract the group label.</li>
<li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li>
<li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li>
<li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li>
<li><b>#SITE</b> - extract the site id like 1.</li>
<li><b>#SITELBL</b> - extract the site label.</li>
<li><b>#SYS</b> - extract the system id like 11.</li>
<li><b>#SYSLBL</b> - extract the system label.</li>
<li><b>#TAG</b> - extract the tag label.</li>
<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>
<li><b>#TGHZ</b> - extract the frequency in hertz like 119100000 and set the talkgroup id to
119100.</li>
<li><b>#TGKHZ</b> - extract the frequency in kilohertz like 119100.000 or 119100 and set the
talkgroup id to 119100.</li>
<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
talkgroup id to 119100.</li>
<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>
<li><b>#UNIT</b> - extract the unit id like 4424001.</li>
<li><b>#UNITLBL</b> - extract the unit label. Also requires #UNIT.</li>
<li><b>#ZTIME</b> - extract the zulu time like 0453439&nbsp;(HHMMSS),
08-34-39&nbsp;(HH-MM-SS) or 04:34:39&nbsp;(HH:MM:SS).</li>
</ul>
Example: cymx_#TG_#DATE_#TIME_#HZ
</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="mask" placeholder="Mask">
@if (dirwatch.get('mask')?.hasError('invalid')) {
<mat-error>
Invalid mask
</mat-error>
}
</mat-form-field>
</div>
}
@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>

View file

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

View file

@ -1,81 +1,101 @@
<div class="row top">
<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>
<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>
</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-expansion-panel *ngFor="let downstream of downstreams; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ downstream.value.url || 'NewDownstream' }}
<mat-icon *ngIf="downstream.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="downstream">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the downstream.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">API Key</span><br>
<span class="mat-caption">Api key of the remote instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input #key type="text" matInput formControlName="apikey" placeholder="API key">
<mat-error *ngIf="downstream.get('apikey')?.hasError('duplicate')">
API key is already defined
</mat-error>
<mat-error *ngIf="downstream.get('apikey')?.hasError('required')">
API key is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">URL</span><br>
<span class="mat-caption">URL of the remote instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="url" placeholder="URL">
<mat-error *ngIf="downstream.get('url')?.hasError('required')">
URL is required
</mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('invalid')">
URL is invalid
</mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('duplicate')">
URL is already defined
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This downstream allows access to <u>
<ng-container *ngIf="downstream.value.systems === '*'">all</ng-container>
<ng-container *ngIf="downstream.value.systems !== '*'">some</ng-container>
</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>
@for (downstream of downstreams; track downstream; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ downstream.value.url || 'NewDownstream' }}
@if (downstream.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="downstream">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the downstream.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">API Key</span><br>
<span class="mat-caption">Api key of the remote instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input #key type="text" matInput formControlName="apikey" placeholder="API key">
@if (downstream.get('apikey')?.hasError('duplicate')) {
<mat-error>
API key is already defined
</mat-error>
}
@if (downstream.get('apikey')?.hasError('required')) {
<mat-error>
API key is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">URL</span><br>
<span class="mat-caption">URL of the remote instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="url" placeholder="URL">
@if (downstream.get('url')?.hasError('required')) {
<mat-error>
URL is required
</mat-error>
}
@if (downstream.get('url')?.hasError('invalid')) {
<mat-error>
URL is invalid
</mat-error>
}
@if (downstream.get('url')?.hasError('duplicate')) {
<mat-error>
URL is already defined
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This downstream allows access to <u>
@if (downstream.value.systems === '*') {
all
}
@if (downstream.value.systems !== '*') {
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>

View file

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

View file

@ -1,68 +1,68 @@
<div class="row top">
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
active talkgroups.</p>
<button type="button" mat-button color="accent" (click)="add()">New Group</button>
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
active talkgroups.</p>
<button type="button" mat-button color="accent" (click)="add()">New Group</button>
</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"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let group of groups; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ group.value.label || 'NewGroup' }}
<mat-icon *ngIf="group.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="group">
<div class="row">
<p>
<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
select panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete Group
</button>
</div>
</ng-container>
</mat-expansion-panel>
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let group of groups; let i = index" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ group.value.label || 'NewGroup' }}
<mat-icon *ngIf="group.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="group">
<div class="row">
<p>
<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
select panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="group.get('label')!.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete Group
</button>
</div>
</ng-container>
</mat-expansion-panel>
</mat-accordion>

View file

@ -18,35 +18,60 @@
*/
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 { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'rdio-scanner-admin-groups',
templateUrl: './groups.component.html',
standalone: false
})
export class RdioScannerAdminGroupsComponent {
@Input() form: FormArray | undefined;
export class RdioScannerAdminGroupsComponent implements OnDestroy {
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[] {
return Object.keys(this.adminService.Alerts || {});
}
return this.alertsArray;
}
get groups(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
if (this.cacheValid && this.cachedGroups) return this.cachedGroups;
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;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) {
this.leds = this.adminService.getLeds();
this.alertsArray = Object.keys(this.adminService.Alerts || {});
}
add(): void {
@ -57,6 +82,7 @@ export class RdioScannerAdminGroupsComponent {
this.form?.insert(0, group);
this.form?.markAsDirty();
this.cacheValid = false;
}
closeAll(): void {
@ -70,6 +96,7 @@ export class RdioScannerAdminGroupsComponent {
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
this.cacheValid = false;
}
}
@ -81,5 +108,10 @@ export class RdioScannerAdminGroupsComponent {
this.form?.removeAt(index);
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">
<p>
<span class="mat-body">12-Hour Time Format</span><br>
<span class="mat-caption">Display time stamp in 12-hour format.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">12-Hour Time Format</span><br>
<span class="mat-caption">Display time stamp in 12-hour format.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Audio Conversion</span><br>
<span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="audioConversion" placeholder="Audio Conversion">
<mat-option [value]="0">Disabled</mat-option>
<mat-option [value]="1">Enabled without normalization</mat-option>
<mat-option [value]="2">Enabled with normalization</mat-option>
<mat-option [value]="3">Enabled with loud normalization</mat-option>
</mat-select>
</mat-form-field>
<p>
<span class="mat-body">Audio Conversion</span><br>
<span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="audioConversion" placeholder="Audio Conversion">
<mat-option [value]="0">Disabled</mat-option>
<mat-option [value]="1">Enabled without normalization</mat-option>
<mat-option [value]="2">Enabled with normalization</mat-option>
<mat-option [value]="3">Enabled with loud normalization</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Globaly allows the automatic creation of unconfigured systems and
talkgroups.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Globally allows the automatic creation of unconfigured systems and
talkgroups.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<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
Scanner instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="branding" placeholder="Branding">
</mat-form-field>
<p>
<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
Scanner instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="branding" placeholder="Branding">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Dimmer Delay</span><br>
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
inactive.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('required')">
Dimmer delay is required
</mat-error>
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('min')">
Dimmer delay is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Dimmer Delay</span><br>
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
inactive.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
<mat-error *ngIf="form.get('dimmerDelay')?.hasError('required')">
Dimmer delay is required
</mat-error>
<mat-error *ngIf="form.get('dimmerDelay')?.hasError('min')">
Dimmer delay is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Disable Duplicate Call Detection</span><br>
<span class="mat-caption">Disable duplicate audio file detection.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Disable Duplicate Call Detection</span><br>
<span class="mat-caption">Disable duplicate audio file detection.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<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
a start time of +/- this delay in milliseconds.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('required')">
Duplicate call time frame is required
</mat-error>
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('min')">
Duplicate call time frame is invalid
</mat-error>
</mat-form-field>
<p>
<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
a start time of +/- this delay in milliseconds.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
<mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('required')">
Duplicate call time frame is required
</mat-error>
<mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('min')">
Duplicate call time frame is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Email Support</span><br>
<span class="mat-caption">Email address where users can write to to get support.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="email" placeholder="Email">
</mat-form-field>
<p>
<span class="mat-body">Email Support</span><br>
<span class="mat-caption">Email address where users can write to to get support.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="email" placeholder="Email">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Keypad Beep Style</span><br>
<span class="mat-caption">Provide audio feedback when pressing buttons.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="keypadBeeps" placeholder="Style">
<mat-option value="uniden">Uniden</mat-option>
<mat-option value="whistler">Whistler</mat-option>
</mat-select>
</mat-form-field>
<p>
<span class="mat-body">Keypad Beep Style</span><br>
<span class="mat-caption">Provide audio feedback when pressing buttons.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="keypadBeeps" placeholder="Style">
<mat-option value="uniden">Uniden</mat-option>
<mat-option value="whistler">Whistler</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Max Clients</span><br>
<span class="mat-caption">Max number of simultaneous clients.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="maxClients">
<mat-error *ngIf="form?.get('maxClients')?.hasError('required')">
Max clients is required
</mat-error>
<mat-error *ngIf="form?.get('maxClients')?.hasError('min')">
Max clients is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Max Clients</span><br>
<span class="mat-caption">Max number of simultaneous clients.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="maxClients">
<mat-error *ngIf="form.get('maxClients')?.hasError('required')">
Max clients is required
</mat-error>
<mat-error *ngIf="form.get('maxClients')?.hasError('min')">
Max clients is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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
exhausted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="playbackGoesLive"></mat-slide-toggle>
</div>
<p>
<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
exhausted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="playbackGoesLive"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<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
disable.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="pruneDays">
<mat-error *ngIf="form?.get('pruneDays')?.hasError('required')">
Prune days is required
</mat-error>
<mat-error *ngIf="form?.get('pruneDays')?.hasError('min')">
Prune days is invalid
</mat-error>
</mat-form-field>
<p>
<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
disable.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="pruneDays">
<mat-error *ngIf="form.get('pruneDays')?.hasError('required')">
Prune days is required
</mat-error>
<mat-error *ngIf="form.get('pruneDays')?.hasError('min')">
Prune days is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Show Listeners Count</span><br>
<span class="mat-caption">Show listeners count on main screen.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="showListenersCount"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Show Listeners Count</span><br>
<span class="mat-caption">Show listeners count on main screen.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="showListenersCount"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Sort Talkgroups</span><br>
<span class="mat-caption">Automatically sort talkgroups by their id.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Sort Talkgroups</span><br>
<span class="mat-caption">Automatically sort talkgroups by their id.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
</div>
</div>
</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';
@Component({
selector: 'rdio-scanner-admin-options',
templateUrl: './options.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
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 mat-dialog-title>Systems and talkgroups selection</div>
<div [formGroup]="select" mat-dialog-content>
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.everything">
Everything
</mat-checkbox>
<h3 class="mat-caption">Groups</h3>
<div class="flex-row wrap" formArrayName="groups">
<div *ngFor="let group of configGroups; index as groupIndex" [formGroupName]="groupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.groups[groupIndex]">
{{ group.value.label }}
</mat-checkbox>
</div>
</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 mat-dialog-title>Systems and talkgroups selection</div>
<div [formGroup]="select" mat-dialog-content>
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.everything">
Everything
</mat-checkbox>
<h3 class="mat-caption">Groups</h3>
<div class="flex-row wrap" formArrayName="groups">
<ng-container *ngFor="let group of configGroups; let groupIndex = index">
<div [formGroupName]="groupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.groups[groupIndex]">
{{ group.value.label }}
</mat-checkbox>
</div>
</ng-container>
</div>
<div mat-dialog-actions>
<button mat-raised-button (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
<h3 class="mat-caption">Tags</h3>
<div class="flex-row wrap" formArrayName="tags">
<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>
<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>

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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Access } from '../../../admin.service';
import { Subscription } from 'rxjs';
interface System {
all: boolean;
@ -38,8 +39,9 @@ interface Talkgroup {
selector: 'rdio-scanner-admin-systems-selection',
styleUrls: ['./select.component.scss'],
templateUrl: './select.component.html',
standalone: false
})
export class RdioScannerAdminSystemsSelectComponent {
export class RdioScannerAdminSystemsSelectComponent implements OnDestroy {
indeterminate = {
everything: false,
groups: [] as boolean[],
@ -53,22 +55,21 @@ export class RdioScannerAdminSystemsSelectComponent {
get configGroups(): FormGroup[] {
const faGroups = this.access.root.get('groups') as FormArray;
return faGroups.controls as FormGroup[];
}
get configSystems(): FormGroup[] {
const faSystems = this.access.root.get('systems') as FormArray;
return faSystems.controls as FormGroup[];
}
get configTags(): FormGroup[] {
const faTags = this.access.root.get('tags') as FormArray;
return faTags.controls as FormGroup[];
}
private subs = new Subscription();
constructor(
@Inject(MAT_DIALOG_DATA) public access: FormGroup,
private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>,
@ -76,7 +77,6 @@ export class RdioScannerAdminSystemsSelectComponent {
) {
this.configTalkgroups = this.configSystems.map((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls as FormGroup[];
});
@ -92,179 +92,140 @@ export class RdioScannerAdminSystemsSelectComponent {
const faSystems = this.select.get('systems') 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({
id: this.ngFormBuilder.control(configGroup.get('id')?.value),
checked: this.ngFormBuilder.control(false),
});
faGroups.push(fgGroup);
fgGroup.valueChanges.subscribe((vGroup) => {
faSystems.controls.forEach((fgSystem) => {
this.subs.add(fgGroup.valueChanges.subscribe((vGroup) => {
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.get('groupIds')?.value.includes(vGroup.id) && fgTalkgroup.get('checked')?.value !== vGroup.checked) {
for (const fgTalkgroup of faTalkgroups.controls) {
const ids = fgTalkgroup.get('groupIds')?.value;
if (ids && ids.includes(vGroup.id) && fgTalkgroup.get('checked')?.value !== 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 faSystemTalkgroups = this.ngFormBuilder.array<FormGroup<{
checked: FormControl<boolean>;
groupIds: FormControl<number[]>;
id: FormControl<number>;
tagId: FormControl<number>;
}>>([]);
const faSystemTalkgroups = this.ngFormBuilder.array<FormGroup>([]);
const fgSystem = this.ngFormBuilder.group({
all: fcSystemAll,
id: this.ngFormBuilder.control(configSystem.get('systemRef')?.value),
talkgroups: faSystemTalkgroups
});
this.configTalkgroups[index].forEach((configTalkgroup) => {
for (const configTalkgroup of this.configTalkgroups[index]) {
const fgSystemTalkgroup = this.ngFormBuilder.group({
checked: this.ngFormBuilder.nonNullable.control(false),
groupIds: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('groupIds')?.value),
id: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('talkgroupRef')?.value),
tagId: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('tagId')?.value),
});
faSystemTalkgroups.push(fgSystemTalkgroup);
fgSystemTalkgroup.valueChanges.subscribe(() => {
const vAll = faSystemTalkgroups.controls.every((systemTalkgroup) => systemTalkgroup.get('checked')?.value);
this.subs.add(fgSystemTalkgroup.valueChanges.subscribe(() => {
const vAll = faSystemTalkgroups.controls.every((t) => t.get('checked')?.value);
fcSystemAll.setValue(vAll, { emitEvent: false });
});
});
}));
}
faSystems.push(fgSystem);
fgSystem.valueChanges.subscribe(() => {
this.subs.add(fgSystem.valueChanges.subscribe(() => {
this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
});
}));
fcSystemAll.valueChanges.subscribe((vAll) => {
this.subs.add(fcSystemAll.valueChanges.subscribe((vAll) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => fgTalkgroup.get('checked')?.setValue(vAll));
for (const fgTalkgroup of faTalkgroups.controls) {
fgTalkgroup.get('checked')?.setValue(vAll);
}
this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
});
}));
faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let off = 0;
this.subs.add(faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let on = 0;
vSystemTalkgroups.forEach((vSystemTalkgroup) => {
if (vSystemTalkgroup.checked) {
on++;
} else {
off++;
}
});
let off = 0;
for (const v of vSystemTalkgroups) {
if (v.checked) on++; else off++;
}
this.indeterminate.systems[index] = !!off && !!on;
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({
id: this.ngFormBuilder.control(configTag.value.id),
checked: this.ngFormBuilder.control(false),
});
faTags.push(fgTag);
fgTag.valueChanges.subscribe((vTag) => {
faSystems.controls.forEach((fgSystem) => {
this.subs.add(fgTag.valueChanges.subscribe((vTag) => {
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
for (const fgTalkgroup of faTalkgroups.controls) {
if (fgTalkgroup.value.tagId === vTag.id && fgTalkgroup.value.checked !== 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;
fcAll.setValue(!off && on, { emitEvent: false });
});
}));
const vAccess: Access = this.access.value;
if (vAccess.systems === '*') {
this.select.get('all')?.setValue(true);
} else if (Array.isArray(vAccess.systems)) {
vAccess.systems.forEach((vSystem) => {
for (const vSystem of vAccess.systems) {
if (typeof vSystem === 'number') {
faSystems.controls.find((fgSystem) => fgSystem.get('id')?.value === vSystem)?.get('all')?.setValue(true);
} else if (vSystem !== null && typeof vSystem === 'object') {
const fgSystem = faSystems.controls.find((fg) => {
return fg.get('id')?.value === vSystem.id;
});
} else if (vSystem && typeof vSystem === 'object') {
const fgSystem = faSystems.controls.find((fg) => fg.get('id')?.value === vSystem.id);
if (fgSystem) {
if (vSystem.talkgroups === '*') {
fgSystem.get('all')?.setValue(true);
} else if (Array.isArray(vSystem.talkgroups)) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
vSystem.talkgroups.forEach((talkgroup: { id: number } | number) => {
for (const talkgroup of vSystem.talkgroups) {
const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id;
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.get('id')?.value === talkgroupId);
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);
}).map((system: System) => {
if (system['all']) {
return {
id: system['id'],
talkgroups: '*',
};
return { id: system['id'], talkgroups: '*' };
} else {
return {
id: system['id'],
@ -297,61 +254,47 @@ export class RdioScannerAdminSystemsSelectComponent {
private rebuildGroupIndeterminates(): void {
const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
faGroups.controls.forEach((fgGroup, index) => {
let off = 0;
for (let index = 0; index < faGroups.length; index++) {
const fgGroup = faGroups.at(index);
let on = 0;
faSystems.controls.forEach((fgSystem) => {
let off = 0;
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
for (const fgTalkgroup of faTalkgroups.controls) {
if (fgTalkgroup.get('groupIds')?.value.includes(fgGroup.get('id')?.value)) {
if (fgTalkgroup.get('checked')?.value) {
on++;
} else {
off++;
}
if (fgTalkgroup.get('checked')?.value) on++; else off++;
}
});
});
}
}
this.indeterminate.groups[index] = !!off && !!on;
fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false });
});
}
}
private rebuildTagIndeterminates(): void {
const faTags = this.select.get('tags') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
faTags.controls.forEach((fgTag, index) => {
let off = 0;
for (let index = 0; index < faTags.length; index++) {
const fgTag = faTags.at(index);
let on = 0;
faSystems.controls.forEach((fgSystem) => {
let off = 0;
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
for (const fgTalkgroup of faTalkgroups.controls) {
if (fgTalkgroup.value.tagId === fgTag.value.id) {
if (fgTalkgroup.value.checked) {
on++;
} else {
off++;
}
if (fgTalkgroup.value.checked) on++; else off++;
}
});
});
}
}
this.indeterminate.tags[index] = !!off && !!on;
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">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Site identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="siteRef" placeholder="Id">
<mat-error *ngIf="form?.get('siteRef')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form?.get('siteRef')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form?.get('siteRef')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Site label.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete site
</button>
</div>
<ng-container *ngIf="form as f" [formGroup]="f">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Site identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<ng-container *ngIf="f.get('siteRef') as siteRef">
<input type="number" min="1" step="1" matInput formControlName="siteRef" placeholder="Id" />
<mat-error *ngIf="siteRef.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="siteRef.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="siteRef.hasError('required')">
Id is required
</mat-error>
</ng-container>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Site label.</span>
</p>
<mat-form-field floatLabel="auto">
<ng-container *ngIf="f.get('label') as labelCtrl">
<input type="text" matInput formControlName="label" placeholder="Label" />
<mat-error *ngIf="labelCtrl.hasError('required')">
Label is required
</mat-error>
</ng-container>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete site
</button>
</div>
</ng-container>

View file

@ -17,15 +17,36 @@
* ****************************************************************************
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { FormGroup, AbstractControl } from '@angular/forms';
@Component({
selector: 'rdio-scanner-admin-site',
templateUrl: './site.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
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>();
constructor(private readonly cd: ChangeDetectorRef) {}
}

View file

@ -1,229 +1,228 @@
<ng-container [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">System identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="systemRef" placeholder="Id">
<mat-error *ngIf="form.get('systemRef')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
</p>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
<div class="row">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field>
</div>
<div class="row">
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="''">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<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
has an effect if the auto populate option is enabled.</span>
</p>
<mat-form-field floatLabel="auto">
<textarea type="text" matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
<mat-error *ngIf="form?.get('blacklists')?.hasError('invalid')">
Comma separated list of talkgroup Ids
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Type of radio system displayed on main screen.
<ul>
<li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">System identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="systemRef" placeholder="Id">
<mat-error *ngIf="form.get('systemRef')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
</p>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
<div class="row">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field>
</div>
<div class="row">
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<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
has an effect if the auto populate option is enabled.</span>
</p>
<mat-form-field floatLabel="auto">
<textarea matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
<mat-error *ngIf="form.get('blacklists')!.hasError('invalid')">
Comma separated list of talkgroup Ids
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Type of radio system displayed on main screen.
<ul>
<li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-accordion displayMode="flat">
<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-panel-title>
Sites
<mat-icon *ngIf="form.get('sites')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
<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>
<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; 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>
<rdio-scanner-admin-site [form]="site" (remove)="removeSite(i)"></rdio-scanner-admin-site>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</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-panel-title>
Talkgroups
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
<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>
<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; 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>
<rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
(remove)="removeTalkgroup(i)">
</rdio-scanner-admin-talkgroup>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</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>
Units
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ unit.value.label?.trim() || 'NewUnit' }}
<mat-icon *ngIf="unit.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; index as i" [formGroup]="unit" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ unit.value.label?.trim() || 'NewUnit' }}
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon>
</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>
</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>

View file

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

View file

@ -1,20 +1,20 @@
<div class="row top">
<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 to rearrange their order.</p>
<button type="button" mat-button color="accent" (click)="add()">New system</button>
<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 to rearrange their order.</p>
<button type="button" mat-button color="accent" (click)="add()">New system</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let system of systems; index as i" [formGroup]="system"
(afterCollapse)="systemComponent.closeAll()" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ system.value.label?.trim() || 'NewSystem' }}
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
</rdio-scanner-admin-system>
</mat-expansion-panel>
<mat-accordion *ngIf="systems" displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let system of systems; let i = index" [formGroup]="system"
(afterCollapse)="systemComponent.closeAll()" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ system.value.label?.trim() || 'NewSystem' }}
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
</rdio-scanner-admin-system>
</mat-expansion-panel>
</mat-accordion>

View file

@ -18,26 +18,44 @@
*/
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 { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'rdio-scanner-admin-systems',
templateUrl: './systems.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RdioScannerAdminSystemsComponent {
@Input() form: FormArray | undefined;
export class RdioScannerAdminSystemsComponent implements OnDestroy {
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[] {
return this.form?.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
return this._systemsCache;
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { }
constructor(private adminService: RdioScannerAdminService, private cd: ChangeDetectorRef) { }
add(): void {
const system = this.adminService.newSystemForm();
@ -47,6 +65,8 @@ export class RdioScannerAdminSystemsComponent {
this.form?.insert(0, system);
this.form?.markAsDirty();
this.updateCache();
}
closeAll(): void {
@ -60,6 +80,8 @@ export class RdioScannerAdminSystemsComponent {
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
this.updateCache();
}
}
@ -67,5 +89,23 @@ export class RdioScannerAdminSystemsComponent {
this.form?.removeAt(index);
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">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Talkgroup identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="talkgroupRef" placeholder="Id">
<mat-error *ngIf="form?.get('talkgroupRef')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form?.get('talkgroupRef')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form?.get('talkgroupRef')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Talkgroup identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="talkgroupRef" placeholder="Id">
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')!.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Name</span><br>
<span class="mat-caption">Talkgroup name displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="name" placeholder="Name">
<mat-error *ngIf="form?.get('name')?.hasError('required')">
Name is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Name</span><br>
<span class="mat-caption">Talkgroup name displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="name" placeholder="Name">
<mat-error *ngIf="form.get('name')!.hasError('required')">
Name is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Groups</span><br>
<span class="mat-caption">Groups to which this talkgoups belongs.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="groupIds" placeholder="Groups" [multiple]="true">
<mat-option *ngFor="let group of groups" [value]="group.id">
{{ group.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form?.get('groupId')?.hasError('required')">
Group is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Groups</span><br>
<span class="mat-caption">Groups to which this talkgroups belongs.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="groupIds" placeholder="Groups" [multiple]="true">
<mat-option *ngFor="let group of groups" [value]="group.id">
{{ group.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form.get('groupIds')?.hasError('required')">
Group is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Tag</span><br>
<span class="mat-caption">Tag to which this talkgoups belongs.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="tagId" placeholder="Tag">
<mat-option *ngFor="let tag of tags" [value]="tag.id">
{{ tag.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form?.get('tagId')?.hasError('required')">
Tag is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Tag</span><br>
<span class="mat-caption">Tag to which this talkgroups belongs.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="tagId" placeholder="Tag">
<mat-option *ngFor="let tag of tags" [value]="tag.id">
{{ tag.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form.get('tagId')!.hasError('required')">
Tag is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event.value)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field>
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field>
</div>
<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" min="0" matInput formControlName="frequency" placeholder="Frequency">
<mat-error *ngIf="form?.get('frequency')?.errors">
Invalid frequency
</mat-error>
</mat-form-field>
<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" min="0" matInput formControlName="frequency" placeholder="Frequency">
<mat-error *ngIf="form.get('frequency')?.errors">
Invalid frequency
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="''">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Type of radio system that will override the type defined at the system level, if any.
<ul>
<li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option>
</mat-select>
</mat-form-field>
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Type of radio system that will override the type defined at the system level, if any.
<ul>
<li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row bottom">
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
Blacklist talkgroup
</button>
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete talkgroup
</button>
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
Blacklist talkgroup
</button>
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete talkgroup
</button>
</div>
</ng-container>
</ng-container>

View file

@ -19,12 +19,12 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({
selector: 'rdio-scanner-admin-talkgroup',
templateUrl: './talkgroup.component.html',
standalone: false
})
export class RdioScannerAdminTalkgroupComponent {
@Input() form: FormGroup | undefined;
@ -33,25 +33,25 @@ export class RdioScannerAdminTalkgroupComponent {
@Output() remove = new EventEmitter<void>();
leds: string[];
leds: string[] = [];
get alerts(): string[] {
return Object.keys(this.adminService.Alerts || {});
}
get groups(): Group[] {
return this.form?.root.get('groups')?.value as Group[];
return (this.form?.root.get('groups')?.value as Group[]) || [];
}
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) {
this.leds = this.adminService.getLeds();
this.leds = this.adminService.getLeds() || [];
}
async playAlert(event: MatSelectChange): Promise<void> {
if (event.value) await this.adminService.playAlert(event.value);
async playAlert(value: string | null): Promise<void> {
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">
<p>
<span class="mat-body">Id</span><br>
<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>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitRef" placeholder="Id">
<mat-error *ngIf="form?.get('unitRef')?.hasError('duplicate')">
Id value is already defined
</mat-error>
<mat-error *ngIf="form?.get('unitRef')?.hasError('min')">
Id value is invalid
</mat-error>
<mat-error *ngIf="form?.get('unitRef')?.hasError('required')">
Id value is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Id</span><br>
<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>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitRef" placeholder="Id">
<mat-error *ngIf="f.get('unitRef')!.hasError('duplicate')">Id value is already defined</mat-error>
<mat-error *ngIf="f.get('unitRef')!.hasError('min')">Id value is invalid</mat-error>
<mat-error *ngIf="f.get('unitRef')!.hasError('required')">Id value is required</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Range from</span><br>
<span class="mat-caption">Unit identifier range from.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitFrom" placeholder="From">
<mat-error *ngIf="form?.get('unitFrom')?.hasError('range')">
From value should be lower than To value
</mat-error>
<mat-error *ngIf="form?.get('unitFrom')?.hasError('min')">
From value To is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Range from</span><br>
<span class="mat-caption">Unit identifier range from.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitFrom" placeholder="From">
<mat-error *ngIf="f.get('unitFrom')!.hasError('range')">From value should be lower than To value</mat-error>
<mat-error *ngIf="f.get('unitFrom')!.hasError('min')">From value is invalid</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Range to</span><br>
<span class="mat-caption">Unit identifier range to.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitTo" placeholder="To">
<mat-error *ngIf="form?.get('unitTo')?.hasError('range')">
To value should be higher than From value
</mat-error>
<mat-error *ngIf="form?.get('unitTo')?.hasError('min')">
To value is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Range to</span><br>
<span class="mat-caption">Unit identifier range to.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitTo" placeholder="To">
<mat-error *ngIf="f.get('unitTo')!.hasError('range')">To value should be higher than From value</mat-error>
<mat-error *ngIf="f.get('unitTo')!.hasError('min')">To value is invalid</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Unit label.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Unit label.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="f.get('label')!.hasError('required')">Label is required</mat-error>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete unit
</button>
<button type="button" mat-button color="warn" (click)="remove.emit()">Delete unit</button>
</div>
</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';
@Component({
selector: 'rdio-scanner-admin-unit',
templateUrl: './unit.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
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">
<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>
<button type="button" mat-button color="accent" (click)="add()">New Tag</button>
<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>
<button type="button" mat-button color="accent" (click)="add()">New Tag</button>
</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"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let tag of tags; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ tag.value.label || 'NewTag' }}
<mat-icon *ngIf="tag.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="tag">
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Tag label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="''">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</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>
(cdkDropListDropped)="drop($event)">
@for (tag of tags; track tag; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ tag.value.label || 'NewTag' }}
@if (tag.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="tag">
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Tag label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
@if (tag.get('label')!.hasError('required')) {
<mat-error>
Label is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<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
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
@for (alert of alerts; track alert) {
<mat-option [value]="alert">
{{ alert | titlecase }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<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.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="''">Default</mat-option>
@for (led of leds; track led) {
<mat-option [value]="led">
{{ led | titlecase }}
</mat-option>
}
</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-accordion>

View file

@ -28,6 +28,7 @@ import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-tags',
templateUrl: './tags.component.html',
standalone: false
})
export class RdioScannerAdminTagsComponent {
@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>
<mat-form-field hideRequiredMarker>
<mat-label>Password</mat-label>
<input matInput formControlName="password" type="password" required>
<mat-error>{{ message }}</mat-error>
<input
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>
<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>

View file

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

View file

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

View file

@ -27,6 +27,7 @@ import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../ad
selector: 'rdio-scanner-admin-logs',
styleUrls: ['./logs.component.scss'],
templateUrl: './logs.component.html',
standalone: false
})
export class RdioScannerAdminLogsComponent {
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-list>
<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 === 'warn'" class="warn" matListItemIcon>warning</mat-icon>
<div mat-line>
<span class="todo">{{ todo.message }}</span>
</div>
</mat-list-item>
<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 === 'warn'" class="warn" matListItemIcon>warning</mat-icon>
<div mat-line>
<span class="todo">{{ todo.message }}</span>
</div>
</mat-list-item>
</mat-list>
</mat-card>
</mat-card>
</ng-container>

View file

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

View file

@ -29,6 +29,7 @@ interface Todo {
selector: 'rdio-scanner-admin-todos',
styleUrls: ['./todos.component.scss'],
templateUrl: './todos.component.html',
standalone: false
})
export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
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 { Config, RdioScannerAdminService } from '../../admin.service';
import packageInfo from '../../../../../../../package.json';
@ -27,6 +27,7 @@ import packageInfo from '../../../../../../../package.json';
selector: 'rdio-scanner-admin-import-export-config',
styleUrls: ['./import-export-config.component.scss'],
templateUrl: './import-export-config.component.html',
standalone: false
})
export class RdioScannerAdminImportExportConfigComponent {
@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
autopopulate feature will automatically create them as audio files are ingested. So give the autopopulate feature a
chance before trying to import talkgroups.</p>
autopopulate feature will automatically create them as audio files are ingested. So give the autopopulate feature a
chance before trying to import talkgroups.</p>
<section>
<p class="mat-body">step 1: read the CSV file</p>
<p class="text-center">
<ng-container *ngIf="csv.length === 0">
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
<br>
<p>
<span class="mat-body-1">Column format is: </span>
<span class="mat-body-1">Id</span>,
<span>unused</span>,
<span class="mat-body-1">Label</span>,
<span>unused</span>,
<span class="mat-body-1">Name</span>,
<span class="mat-body-1">Tag</span>,
<span class="mat-body-1">Group</span>
</p>
</ng-container>
<ng-container *ngIf="csv.length !== 0">
<button mat-raised-button (click)="csv=[]">Reset</button>
</ng-container>
<p class="mat-body">step 1: read the CSV file</p>
<p class="text-center">
@if (csv.length === 0) {
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
<br>
<p>
<span class="mat-body-1">Column format is: </span>
<span class="mat-body-1">Id</span>,
<span>unused</span>,
<span class="mat-body-1">Label</span>,
<span>unused</span>,
<span class="mat-body-1">Name</span>,
<span class="mat-body-1">Tag</span>,
<span class="mat-body-1">Group</span>
</p>
}
@if (csv.length !== 0) {
<button mat-raised-button (click)="csv=[]">Reset</button>
}
</p>
</section>
</section>
<section>
<section>
<p class="mat-body">step 2: select system</p>
<p class="text-center">
<mat-form-field floatLabel="auto">
<mat-select [(ngModel)]="system">
<mat-option *ngFor="let system of baseConfig.systems" [value]="system">
{{ system.label}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field floatLabel="auto">
<mat-select [(ngModel)]="system">
@for (system of baseConfig.systems; track system) {
<mat-option [value]="system">
{{ system.label}}
</mat-option>
}
</mat-select>
</mat-form-field>
</p>
</section>
</section>
<section>
<section>
<p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll">
<mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>
<span>Id</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[0] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef>
<span>Label</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[2] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>
<span>Description</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[4] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="tag">
<mat-header-cell *matHeaderCellDef>
<span>Tag</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[5] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef>
<span>Group</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[6] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table>
<mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>
<span>Id</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[0] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef>
<span>Label</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[2] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>
<span>Description</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[4] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="tag">
<mat-header-cell *matHeaderCellDef>
<span>Tag</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[5] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef>
<span>Group</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[6] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table>
</div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p>
</section>
@if (!csv.length) {
<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="text-center">
<button mat-raised-button [disabled]="!csv.length" (click)="import()">
Import to configuration
</button>
<button mat-raised-button [disabled]="!csv.length" (click)="import()">
Import to configuration
</button>
</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
has not designed for this number of talkgroups. You can continue, but expect Rdio Scanner to be quite slow, if
not unusable.
</p>
</section>
</p>
}
</section>

View file

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

View file

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

View file

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

View file

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

View file

@ -18,61 +18,68 @@
*/
import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, ValidatorFn, ValidationErrors, Validators } from '@angular/forms';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-password',
styleUrls: ['./password.component.scss'],
templateUrl: './password.component.html',
standalone: false
})
export class RdioScannerAdminPasswordComponent {
form: FormGroup;
constructor(
private adminService: RdioScannerAdminService,
private matSnackBar: MatSnackBar,
private ngFormBuilder: FormBuilder,
private readonly adminService: RdioScannerAdminService,
private readonly matSnackBar: MatSnackBar,
private readonly ngFormBuilder: FormBuilder,
) {
this.form = this.ngFormBuilder.group({
currentPassword: [null, Validators.required],
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 {
return (control: AbstractControl): ValidationErrors | null => {
return typeof control.value === 'string' && control.value.length
? control.value === control.parent?.value.newPassword
? null : { invalid: true } : null;
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const newPwd = group.get('newPassword')?.value;
const verify = group.get('verifyNewPassword')?.value;
return newPwd && verify && newPwd !== verify ? { passwordMismatch: 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 {
this.form.reset();
}
async save(): Promise<void> {
const config: MatSnackBarConfig = { duration: 5000 };
const currentPassword = this.form.get('currentPassword')?.value;
const newPassword = this.form.get('newPassword')?.value;
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);
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
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({
selector: 'rdio-scanner-admin-tools',
templateUrl: './tools.component.html',
standalone: false
})
export class RdioScannerAdminToolsComponent {
@Output() config = new EventEmitter<Config>();

View file

@ -1,177 +1,193 @@
<div class="rdio-status">
<div class="branding">{{ branding }}</div>
<div class="led" [ngClass]="ledStyle"></div>
<div class="branding">{{ branding }}</div>
<div class="led" [ngClass]="ledStyle"></div>
</div>
<div class="rdio-display" [ngClass]="{ idle: !dimmer }" (dblclick)="toggleFullscreen.emit()">
<div class="row three">
<div>
<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 class="row three">
<div>
<span>{{ clock | date:timeFormat }}</span>
</div>
<div class="row small"></div>
<div class="row">
<div>
<span>{{ callSystem }}</span>
</div>
<div>
<span>{{ type | uppercase }}</span>
</div>
<div>
<span>{{ callTag }}</span>
</div>
<div>
@if (!linked) {
<span>NO LINK</span>
}
@if (linked && showListenersCount) {
<span>L: {{ listeners }}</span>
}
</div>
<div class="row">
<div>
<span>{{ callTalkgroup }}</span>
</div>
<div></div>
<div>
<span>{{ callDate | date:'MM/dd' }} </span>
<span>{{ callProgress | date:timeFormat }}</span>
</div>
<div>
<span>Q: {{ callQueue }}</span>
</div>
<div class="row big full">
<span>{{ callTalkgroupName }}</span>
</div>
<div class="row small"></div>
<div class="row">
<div>
<span>{{ callSystem }}</span>
</div>
<div class="row">
<div>
<span>F: {{ callFrequency || 0 }}</span>
</div>
<div></div>
<div>
<span>TGID: {{ callTalkgroupId || 0 }}</span>
</div>
<div>
<span>{{ type | uppercase }}</span>
</div>
<div class="row">
<div>
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }} </span>
</div>
<div></div>
<div>
<span *ngIf="callUnit">UID: {{ callUnit }}</span>
</div>
<div>
<span>{{ callTag }}</span>
</div>
<div class="row right small">
<div></div>
<div></div>
</div>
<div class="row">
<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 *ngIf="tempAvoid">
<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>
<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 class="wrapper">
<div class="auth" [ngClass]="{ visible: auth }">
<form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()">
<mat-form-field>
<input #password matInput type="password" autocomplete="off" formControlName="password"
placeholder="Unlock code" (blur)="authFocus()">
<mat-error *ngIf="authForm.get('password')?.hasError('expired')">
This unlock code has expired
</mat-error>
<mat-error *ngIf="authForm.get('password')?.hasError('tooMany')">
Too many connections
</mat-error>
</mat-form-field>
</form>
</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>
<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 class="wrapper">
<div class="auth" [ngClass]="{ visible: auth }">
<form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()">
<mat-form-field>
<input #password matInput type="password" autocomplete="off" formControlName="password"
placeholder="Unlock code" (blur)="authFocus()">
@if (authForm.get('password')?.hasError('expired')) {
<mat-error>
This unlock code has expired
</mat-error>
}
@if (authForm.get('password')?.hasError('tooMany')) {
<mat-error>
Too many connections
</mat-error>
}
</mat-form-field>
</form>
</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 class="rdio-control">
<div class="row">
<button class="rdio-button" (click)="livefeed()"
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
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>
<div class="row">
<button class="rdio-button" (click)="livefeed()"
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
LIVE<br>FEED
</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',
],
templateUrl: './main.component.html',
standalone: false
})
export class RdioScannerMainComponent implements OnDestroy, OnInit {
auth = false;
@ -530,7 +531,7 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
}
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 {

View file

@ -1,10 +1,10 @@
<span class="support">
<ng-container *ngIf="email">
For further assistance please contact <b><a href="mailto:{{ email }}" target="_blank">{{ email }}</a></b>
</ng-container>
<ng-container *ngIf="!email">
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>
</ng-container>
@if (email) {
For further assistance please contact <b><a href="mailto:{{ email }}" target="_blank">{{ email }}</a></b>
}
@if (!email) {
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>
}
</span>
<span class="mat-caption countdown">{{ countdown }}</span>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ import { RdioScannerNativeComponent } from './native/native.component';
selector: 'rdio-scanner',
styleUrls: ['./rdio-scanner.component.scss'],
templateUrl: './rdio-scanner.component.html',
standalone: false
})
export class RdioScannerComponent implements OnDestroy, OnInit {
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 { interval, Subscription, timer } from 'rxjs';
import { takeWhile } from 'rxjs/operators';

View file

@ -1,165 +1,181 @@
<mat-card appearance="outlined" class="rdio-list">
<mat-table [dataSource]="results">
<ng-container matColumnDef="control">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row">
<button *ngIf="downloadMode.checked && row" mat-icon-button (click)="download(+row.id)">
<mat-icon>save_alt</mat-icon>
</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-table [dataSource]="results">
<ng-container matColumnDef="control">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row">
@if (downloadMode.checked && row) {
<button mat-icon-button (click)="download(+row.id)">
<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>
</button>
}
@if (row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending) {
<button
mat-icon-button (click)="play(+row.id)">
<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 appearance="outlined" class="rdio-filters">
<form autocomplete="off" [formGroup]="form">
<mat-form-field>
<mat-label>
Sort order
</mat-label>
<mat-select formControlName="sort" (selectionChange)="formChangeHandler()">
<mat-option [value]="1">
Ascending
</mat-option>
<mat-option [value]="-1">
Descending
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Date
</mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop"
[min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()">
</mat-form-field>
<mat-form-field>
<mat-label>
System
</mat-label>
<mat-select formControlName="system" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Systems
</mat-option>
<mat-option *ngFor="let system of optionsSystem; index as i" [value]="i">
{{ system }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Talkgroup
</mat-label>
<mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Talkgroups
</mat-option>
<mat-option *ngFor="let talkgroup of optionsTalkgroup; index as i" [value]="i">
{{ talkgroup }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Group
</mat-label>
<mat-select formControlName="group" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Groups
</mat-option>
<mat-option *ngFor="let group of optionsGroup; index as i" [value]="i">
{{ group }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Tag
</mat-label>
<mat-select formControlName="tag" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Tags
</mat-option>
<mat-option *ngFor="let tag of optionsTag; index as i" [value]="i">
{{ tag }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Unit
</mat-label>
<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>
<form autocomplete="off" [formGroup]="form">
<mat-form-field>
<mat-label>
Sort order
</mat-label>
<mat-select formControlName="sort" (selectionChange)="formChangeHandler()">
<mat-option [value]="1">
Ascending
</mat-option>
<mat-option [value]="-1">
Descending
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Date
</mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop"
[min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()">
</mat-form-field>
<mat-form-field>
<mat-label>
System
</mat-label>
<mat-select formControlName="system" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Systems
</mat-option>
@for (system of optionsSystem; track system; let i = $index) {
<mat-option [value]="i">
{{ system }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Talkgroup
</mat-label>
<mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Talkgroups
</mat-option>
@for (talkgroup of optionsTalkgroup; track talkgroup; let i = $index) {
<mat-option [value]="i">
{{ talkgroup }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Group
</mat-label>
<mat-select formControlName="group" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Groups
</mat-option>
@for (group of optionsGroup; track group; let i = $index) {
<mat-option [value]="i">
{{ group }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Tag
</mat-label>
<mat-select formControlName="tag" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Tags
</mat-option>
@for (tag of optionsTag; track tag; let i = $index) {
<mat-option [value]="i">
{{ tag }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Unit
</mat-label>
<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>

View file

@ -37,6 +37,7 @@ import { RdioScannerService } from '../rdio-scanner.service';
selector: 'rdio-scanner-search',
styleUrls: ['./search.component.scss'],
templateUrl: './search.component.html',
standalone: false
})
export class RdioScannerSearchComponent implements OnDestroy {
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>
<button *ngFor="let category of categories" class="rdio-button" [ngClass]="category.status"
(click)="toggle(category)">
{{ category.label }}
</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
@for (category of categories; track category) {
<button class="rdio-button" [ngClass]="category.status"
(click)="toggle(category)">
{{ category.label }}
</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>
</fieldset>
<ng-container *ngFor="let system of systems">
<fieldset *ngIf="system.talkgroups.length" class="fieldset">
<legend>
{{ system.label }}
</legend>
<div>
<button *ngFor="let talkgroup of system.talkgroups" class="rdio-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 }"
(click)="avoid({ system: system, talkgroup: talkgroup })">
{{ talkgroup.label }}
</button>
<ng-container *ngIf="system.talkgroups.length > 1">
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON
</button>
</ng-container>
</div>
</fieldset>
}
@for (system of systems; track system) {
@if (system.talkgroups.length) {
<fieldset class="fieldset">
<legend>
{{ system.label }}
</legend>
<div>
@for (talkgroup of system.talkgroups; track talkgroup) {
<button class="rdio-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 }"
(click)="avoid({ system: system, talkgroup: talkgroup })">
{{ talkgroup.label }}
</button>
}
@if (system.talkgroups.length > 1) {
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON
</button>
}
</div>
</fieldset>
</ng-container>
<ng-container *ngIf="systems?.length === 0">
No talkgroup configured!
</ng-container>
}
}
@if (systems?.length === 0) {
No talkgroup configured!
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,8 +17,8 @@
* ****************************************************************************
*/
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { enableProdMode, provideZoneChangeDetection } from '@angular/core';
import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@ -27,4 +27,4 @@ if (environment.production) {
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;
@import "./styles/material-icons";
@import "./styles/roboto-fonts";
@use "./styles/material-icons" as *;
@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-primary: mat.define-palette(mat.$grey-palette, 800, 50);
$rdio-scanner-accent: mat.define-palette(mat.$green-palette);
$rdio-scanner-warn: mat.define-palette(mat.$red-palette);
$rdio-scanner-typography: mat.define-typography-config(
$rdio-scanner-primary: mat.m2-define-palette(mat.$m2-grey-palette, 800, 50);
$rdio-scanner-accent: mat.m2-define-palette(mat.$m2-green-palette);
$rdio-scanner-warn: mat.m2-define-palette(mat.$m2-red-palette);
$rdio-scanner-typography: mat.m2-define-typography-config(
$font-family: $rdio-scanner-font-family,
);
$rdio-scanner-theme: mat.define-light-theme(
$rdio-scanner-theme: mat.m2-define-light-theme(
(
color: (
primary: $rdio-scanner-primary,

View file

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