Incidents UI
This commit is contained in:
parent
af950c2da7
commit
41d75e512b
7 changed files with 524 additions and 19 deletions
|
@ -55,7 +55,7 @@ export class TimePipe implements PipeTransform {
|
|||
return timestamp.toLocaleTimeString(navigator.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h24',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -274,7 +274,9 @@ export class CallsComponent {
|
|||
this.pageWindow = pageStart % reqPageSize;
|
||||
if (serverPage == this.currentServerPage && !force && this.currentSet) {
|
||||
this.callsResult.next(
|
||||
this.callsResult ? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize) : [],
|
||||
this.callsResult
|
||||
? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize)
|
||||
: [],
|
||||
);
|
||||
} else {
|
||||
this.currentServerPage = serverPage;
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import { CallRecord } from "./calls";
|
||||
import { CallRecord } from './calls';
|
||||
|
||||
export interface IncidentCall extends CallRecord {
|
||||
notes: Object|null;
|
||||
notes: Object | null;
|
||||
}
|
||||
|
||||
export interface IncidentRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string|null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
location: Object|null;
|
||||
metadata: Object|null;
|
||||
calls: IncidentCall[]|null;
|
||||
}
|
||||
id: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
location: Object | null;
|
||||
metadata: Object | null;
|
||||
calls: IncidentCall[] | null;
|
||||
callCount: number | null;
|
||||
}
|
||||
|
|
|
@ -1 +1,126 @@
|
|||
<p>incidents works!</p>
|
||||
<div [ngClass]="{ hidden: (tcSvc.filterPanel | async) }" class="toolbar">
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
|
||||
<mat-label>Start</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="datetime-local"
|
||||
name="start"
|
||||
placeholder="Start date"
|
||||
formControlName="start"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
|
||||
<mat-label>End</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="datetime-local"
|
||||
name="end"
|
||||
placeholder="End date"
|
||||
formControlName="end"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="filterBox" subscriptSizing="dynamic">
|
||||
<mat-label>Filter</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="filter"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
formControlName="filter"
|
||||
/>
|
||||
<button
|
||||
class="clearBtn"
|
||||
*ngIf="form.controls['filter'].value"
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
(click)="form.controls['filter'].setValue('')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<div class="toolbarButtons">
|
||||
<button class="sbButton" (click)="resetFilter()">
|
||||
<mat-icon class="material-symbols-outlined">reset_settings</mat-icon>
|
||||
</button>
|
||||
<button class="sbButton" (click)="refresh()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tabContainer" *ngIf="!isLoading; else spinner">
|
||||
<table class="incsTable" mat-table [dataSource]="incsResult">
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<mat-checkbox
|
||||
(change)="$event ? masterToggle() : null"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-checkbox
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(row) : null"
|
||||
[checked]="selection.isSelected(row)"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="startTime">
|
||||
<th mat-header-cell *matHeaderCellDef>Start</th>
|
||||
<td mat-cell *matCellDef="let incident">
|
||||
{{ incident.startTime | fmtDate }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="endTime">
|
||||
<th mat-header-cell *matHeaderCellDef>End</th>
|
||||
<td [title]="incident.incident_date" mat-cell *matCellDef="let incident">
|
||||
{{ incident.endTime | fmtDate }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let incident">
|
||||
{{ incident.name }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="numCalls">
|
||||
<th mat-header-cell *matHeaderCellDef>Calls</th>
|
||||
<td mat-cell *matCellDef="let incident">
|
||||
{{ incident.callCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="edit">
|
||||
<th mat-header-cell *matHeaderCellDef>Edit</th>
|
||||
<td mat-cell *matCellDef="let incident">
|
||||
<a routerLink="/incidents/{{ incident.id }}"
|
||||
><mat-icon>edit</mat-icon>
|
||||
</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
|
||||
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagFoot">
|
||||
<mat-paginator
|
||||
#paginator
|
||||
class="paginator"
|
||||
(page)="setPage($event)"
|
||||
[length]="count"
|
||||
showFirstLastButtons="true"
|
||||
[pageSize]="curPage.pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="curPage.pageIndex"
|
||||
aria-label="Select page"
|
||||
>
|
||||
</mat-paginator>
|
||||
</div>
|
||||
<ng-template #spinner>
|
||||
<div class="spinner">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
.timeFilterBox {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
table,
|
||||
.incsTable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.duration {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
max-height: calc(
|
||||
(
|
||||
100vh - var(--mat-mat-maginator-container-size, 56px) - 2 *
|
||||
(var(--mat-toolbar-standard-height, 64px))
|
||||
) + 7px
|
||||
);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.toolbarButtons button,
|
||||
.toolbarButtons form,
|
||||
.toolbarButtons form button {
|
||||
justify-content: flex-end;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.mat-mdc-row {
|
||||
height: 2.3rem !important;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.mdc-text-field__input::-webkit-calendar-picker-indicator {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.mat-column-select,
|
||||
.mat-column-time,
|
||||
.mat-column-play,
|
||||
.mat-column-download,
|
||||
.mat-column-duration {
|
||||
width: 10px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.mat-column-date {
|
||||
width: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.mat-column-system {
|
||||
width: 4%;
|
||||
}
|
||||
|
||||
.mat-column-talkgroup {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.mat-column-group,
|
||||
.mat-column-system {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabFootContainer {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.toolbar form {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
form {
|
||||
flex: 1 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filterBox {
|
||||
flex: 1 1 300px;
|
||||
}
|
||||
|
||||
.durationFilter {
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
|
||||
.tagSelect {
|
||||
flex: 1 1 250px;
|
||||
}
|
|
@ -1,9 +1,246 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { Component, Pipe, PipeTransform, ViewChild } from '@angular/core';
|
||||
import { CommonModule, AsyncPipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import {
|
||||
MatPaginator,
|
||||
MatPaginatorModule,
|
||||
PageEvent,
|
||||
} from '@angular/material/paginator';
|
||||
import { PrefsService } from '../prefs/prefs.service';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { IncidentsListParams, IncidentsService } from './incidents.service';
|
||||
import { IncidentRecord } from '../incidents';
|
||||
|
||||
import { TalkgroupService } from '../talkgroups/talkgroups.service';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTimepickerModule } from '@angular/material/timepicker';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { ToolbarContextService } from '../navigation/toolbar-context.service';
|
||||
|
||||
@Pipe({
|
||||
name: 'fmtDate',
|
||||
standalone: true,
|
||||
pure: true,
|
||||
})
|
||||
export class DatePipe implements PipeTransform {
|
||||
transform(ts: string, args?: any): string {
|
||||
if (!ts) {
|
||||
return '\u2014';
|
||||
}
|
||||
const timestamp = new Date(ts);
|
||||
return (
|
||||
timestamp.getMonth() +
|
||||
1 +
|
||||
'/' +
|
||||
timestamp.getDate() +
|
||||
' ' +
|
||||
timestamp.toLocaleTimeString(navigator.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reqPageSize = 200;
|
||||
|
||||
@Component({
|
||||
selector: 'app-incidents',
|
||||
imports: [],
|
||||
imports: [
|
||||
MatIconModule,
|
||||
DatePipe,
|
||||
MatPaginatorModule,
|
||||
MatTableModule,
|
||||
AsyncPipe,
|
||||
MatFormFieldModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
MatInputModule,
|
||||
MatDatepickerModule,
|
||||
MatTimepickerModule,
|
||||
MatCheckboxModule,
|
||||
RouterLink,
|
||||
CommonModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './incidents.component.html',
|
||||
styleUrl: './incidents.component.scss',
|
||||
})
|
||||
export class IncidentsComponent {}
|
||||
export class IncidentsComponent {
|
||||
incsResult = new BehaviorSubject(new Array<IncidentRecord>(0));
|
||||
@ViewChild('paginator') paginator!: MatPaginator;
|
||||
count = 0;
|
||||
page = 0;
|
||||
perPage = 25;
|
||||
pageSizeOptions = [25, 50, 75, 100, 200];
|
||||
columns = ['startTime', 'endTime', 'name', 'numCalls', 'edit'];
|
||||
curPage = <PageEvent>{ pageIndex: 0, pageSize: 0 };
|
||||
currentSet!: IncidentRecord[];
|
||||
currentServerPage = 0; // page is never 0, forces load
|
||||
isLoading = true;
|
||||
|
||||
selection = new SelectionModel<IncidentRecord>(true, []);
|
||||
|
||||
form = new FormGroup({
|
||||
start: new FormControl(null),
|
||||
end: new FormControl(null),
|
||||
filter: new FormControl(''),
|
||||
});
|
||||
|
||||
subscriptions = new Subscription();
|
||||
pageWindow = 0;
|
||||
fetchIncidents = new BehaviorSubject<IncidentsListParams>(
|
||||
this.buildParams(this.curPage, this.curPage.pageIndex),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private incidentsSvc: IncidentsService,
|
||||
private prefsSvc: PrefsService,
|
||||
public tcSvc: ToolbarContextService,
|
||||
public tgSvc: TalkgroupService,
|
||||
) {
|
||||
this.tcSvc.showFilterButton();
|
||||
}
|
||||
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.curPage.pageSize;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
buildParams(p: PageEvent, serverPage: number): IncidentsListParams {
|
||||
const par: IncidentsListParams = {
|
||||
start:
|
||||
this.form.controls['start'].value != null
|
||||
? new Date(this.form.controls['start'].value!)
|
||||
: null,
|
||||
page: serverPage,
|
||||
perPage: reqPageSize,
|
||||
end:
|
||||
this.form.controls['end'].value != null
|
||||
? new Date(this.form.controls['end'].value!)
|
||||
: null,
|
||||
dir: 'desc',
|
||||
filter:
|
||||
this.form.controls['filter'].value != ''
|
||||
? this.form.controls['filter'].value
|
||||
: null,
|
||||
};
|
||||
|
||||
return par;
|
||||
}
|
||||
|
||||
masterToggle() {
|
||||
this.isAllSelected()
|
||||
? this.selection.clear()
|
||||
: this.incsResult.value.forEach((row) => this.selection.select(row));
|
||||
}
|
||||
|
||||
lTime(now: Date): string {
|
||||
now.setDate(new Date().getDate() - 7);
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
return now.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
setPage(p: PageEvent, force?: boolean) {
|
||||
this.selection.clear();
|
||||
this.curPage = p;
|
||||
if (p && p!.pageSize != this.perPage) {
|
||||
this.perPage = p!.pageSize;
|
||||
this.prefsSvc.set('incidentsPerPage', p!.pageSize);
|
||||
}
|
||||
this.getIncidents(p, force);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.selection.clear();
|
||||
this.getIncidents(this.curPage, true);
|
||||
}
|
||||
|
||||
getIncidents(p: PageEvent, force?: boolean) {
|
||||
const pageStart = p.pageIndex * p.pageSize;
|
||||
const serverPage = Math.floor(pageStart / reqPageSize) + 1;
|
||||
this.pageWindow = pageStart % reqPageSize;
|
||||
if (serverPage == this.currentServerPage && !force && this.currentSet) {
|
||||
this.incsResult.next(
|
||||
this.incsResult
|
||||
? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize)
|
||||
: [],
|
||||
);
|
||||
} else {
|
||||
this.currentServerPage = serverPage;
|
||||
this.fetchIncidents.next(this.buildParams(p, serverPage));
|
||||
}
|
||||
}
|
||||
|
||||
zeroPage(): PageEvent {
|
||||
return <PageEvent>{
|
||||
pageIndex: 0,
|
||||
pageSize: this.curPage.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.tcSvc.hideFilterButton();
|
||||
this.subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.form.valueChanges.pipe(debounceTime(300)).subscribe(() => {
|
||||
this.currentServerPage = 0;
|
||||
this.setPage(this.zeroPage(), true);
|
||||
});
|
||||
this.subscriptions.add(
|
||||
this.prefsSvc.get('incidentsPerPage').subscribe((cpp) => {
|
||||
if (cpp && cpp != this.perPage) {
|
||||
this.perPage = cpp;
|
||||
|
||||
this.setPage(<PageEvent>{
|
||||
pageIndex: 0,
|
||||
pageSize: cpp,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.subscriptions.add(
|
||||
this.fetchIncidents
|
||||
.pipe(
|
||||
switchMap((params) => {
|
||||
return this.incidentsSvc.getIncidents(params);
|
||||
}),
|
||||
)
|
||||
.subscribe((incidents) => {
|
||||
this.isLoading = false;
|
||||
this.count = incidents.count;
|
||||
this.currentSet = incidents.incidents;
|
||||
this.incsResult.next(
|
||||
this.currentSet
|
||||
? this.currentSet.slice(
|
||||
this.pageWindow,
|
||||
this.pageWindow + this.perPage,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
resetFilter() {
|
||||
this.form.reset();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,50 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { IncidentRecord } from '../incidents';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface IncidentsListParams {
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
filter: string | null;
|
||||
dir: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
export interface CallIncidentParams {
|
||||
add: string[] | null; // IDs
|
||||
notes: Object | null;
|
||||
remove: string[] | null; // IDs
|
||||
}
|
||||
|
||||
export interface IncidentsPaginated {
|
||||
incidents: IncidentRecord[];
|
||||
count: number;
|
||||
}
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class IncidentsService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
constructor() { }
|
||||
getIncidents(p: IncidentsListParams): Observable<IncidentsPaginated> {
|
||||
return this.http.post<IncidentsPaginated>('/api/incident/', p);
|
||||
}
|
||||
|
||||
createIncident(inp: IncidentRecord): Observable<IncidentRecord> {
|
||||
return this.http.post<IncidentRecord>('/api/incident/new', inp);
|
||||
}
|
||||
|
||||
addRemoveCalls(id: string, inp: CallIncidentParams): Observable<void> {
|
||||
return this.http.post<void>('/api/incident/' + id + '/calls', inp);
|
||||
}
|
||||
|
||||
deleteIncident(id: string): Observable<void> {
|
||||
return this.http.delete<void>('/api/incident/' + id);
|
||||
}
|
||||
|
||||
updateIncident(id: string, inp: IncidentRecord): Observable<IncidentRecord> {
|
||||
return this.http.patch<IncidentRecord>('/api/incident/' + id, inp);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -233,5 +233,6 @@ input {
|
|||
.spinner {
|
||||
display: flex;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue