Incidents UI

This commit is contained in:
Daniel Ponte 2024-12-29 19:29:04 -05:00
parent af950c2da7
commit 41d75e512b
7 changed files with 524 additions and 19 deletions

View file

@ -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;

View file

@ -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;
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -233,5 +233,6 @@ input {
.spinner {
display: flex;
margin-top: 40px;
margin-bottom: 40px;
justify-content: center;
}