WIP support for stats page

This commit is contained in:
Matteo Gheza 2023-12-31 15:30:39 +01:00
parent c5a144d2ff
commit 6d4db17e8f
26 changed files with 610 additions and 5 deletions

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\Place;
use App\Models\Service;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class StatsController extends Controller
{
/**
* Get all services with all data
*/
public function services(Request $request)
{
$query = Service::select('id','code','chief_id','type_id','place_id','notes','start','end','added_by_id','created_at')
->with('place')
->with('drivers:id')
->with('crew:id')
->orderBy('start', 'desc');
if($request->has('from')) {
try {
$from = Carbon::parse($request->input('from'));
$query->whereDate('start', '>=', $from->toDateString());
} catch (\Carbon\Exceptions\InvalidFormatException $e) { }
}
if($request->has('to')) {
try {
$to = Carbon::parse($request->input('to'));
$query->whereDate('start', '<=', $to->toDateString());
} catch (\Carbon\Exceptions\InvalidFormatException $e) { }
}
return response()->json(
$query->get()
);
}
}

View File

@ -9,6 +9,7 @@ use App\Http\Controllers\AlertController;
use App\Http\Controllers\LogsController;
use App\Http\Controllers\TelegramController;
use App\Http\Controllers\ServiceController;
use App\Http\Controllers\StatsController;
use App\Http\Controllers\PlacesController;
use App\Http\Controllers\ServiceTypeController;
use App\Http\Controllers\TrainingController;
@ -29,6 +30,8 @@ use \Matthewbdaly\ETagMiddleware\ETag;
Route::post('/login', [AuthController::class, 'login']);
Route::get('/stats/services', [StatsController::class, 'services'])->middleware(ETag::class);
Route::middleware('auth:sanctum')->group( function () {
//Route::post('/register', [AuthController::class, 'register']); //TODO: replace with admin only route
@ -72,6 +75,8 @@ Route::middleware('auth:sanctum')->group( function () {
Route::get('/logs', [LogsController::class, 'index'])->middleware(ETag::class);
//Route::get('/stats/services', [StatsController::class, 'services'])->middleware(ETag::class);
Route::post('/telegram_login_token', [TelegramController::class, 'loginToken']);
Route::post('/logout', [AuthController::class, 'logout']);

View File

@ -23,10 +23,14 @@
"@ngx-translate/http-loader": "^7.0.0",
"@sentry/angular-ivy": "^7.66.0",
"bootstrap": "^5.1.3",
"chart.js": "^3.3.2",
"chartjs-plugin-colorschemes-v3": "^0.5.4",
"leaflet": "^1.7.1",
"leaflet.locatecontrol": "^0.76.0",
"ngx-bootstrap": "^10.1.0",
"ngx-toastr": "^18.0.0",
"primeicons": "^6.0.1",
"primeng": "17.2.0",
"rxjs": "~7.4.0",
"sweetalert2": "^11.3.4",
"tslib": "^2.3.0",
@ -36,6 +40,7 @@
"@angular-devkit/build-angular": "^17.0.8",
"@angular/cli": "^17.0.8",
"@angular/compiler-cli": "^17.0.8",
"@types/chartjs-plugin-colorschemes": "^0.4.5",
"@types/jasmine": "~3.10.0",
"@types/leaflet": "^1.7.8",
"@types/node": "^20.10.5",
@ -3849,6 +3854,24 @@
"@types/node": "*"
}
},
"node_modules/@types/chart.js": {
"version": "2.9.41",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz",
"integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==",
"dev": true,
"dependencies": {
"moment": "^2.10.2"
}
},
"node_modules/@types/chartjs-plugin-colorschemes": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@types/chartjs-plugin-colorschemes/-/chartjs-plugin-colorschemes-0.4.5.tgz",
"integrity": "sha512-KV41H9rYvGOWuIHAmam4t7tJCT6SBZEAbXrjd4nL65EBmMT8Ze85UCHU6sYO9FHGFqAIg3iGoRlJ0HONXNuOGA==",
"dev": true,
"dependencies": {
"@types/chart.js": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@ -5314,6 +5337,19 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"node_modules/chart.js": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz",
"integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w=="
},
"node_modules/chartjs-plugin-colorschemes-v3": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/chartjs-plugin-colorschemes-v3/-/chartjs-plugin-colorschemes-v3-0.5.4.tgz",
"integrity": "sha512-2YsZT7rkApsJBh1V2JrvtxWRCo6ouuIJqgok97ihYNjVEf5iVFfXYiD6sumaRWQ3elxF8yIywbvOjmpobp0dYg==",
"peerDependencies": {
"chart.js": ">= 3 < 4"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@ -9349,6 +9385,15 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/mrmime": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz",
@ -10528,6 +10573,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/primeicons": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz",
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA=="
},
"node_modules/primeng": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/primeng/-/primeng-17.2.0.tgz",
"integrity": "sha512-pajexYLeJWE6+pmDy0gHrMiQ/zLq4il54EcRNWC0jTNgJvWhoz/K/7NL9kXf2H4eWexNcrEieLnO9ko5uj3cVg==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^17.0.0",
"@angular/core": "^17.0.0",
"@angular/forms": "^17.0.0",
"rxjs": "^6.0.0 || ^7.8.1",
"zone.js": "~0.14.0"
}
},
"node_modules/proc-log": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",

View File

@ -28,10 +28,14 @@
"@ngx-translate/http-loader": "^7.0.0",
"@sentry/angular-ivy": "^7.66.0",
"bootstrap": "^5.1.3",
"chart.js": "^3.3.2",
"chartjs-plugin-colorschemes-v3": "^0.5.4",
"leaflet": "^1.7.1",
"leaflet.locatecontrol": "^0.76.0",
"ngx-bootstrap": "^10.1.0",
"ngx-toastr": "^18.0.0",
"primeicons": "^6.0.1",
"primeng": "17.2.0",
"rxjs": "~7.4.0",
"sweetalert2": "^11.3.4",
"tslib": "^2.3.0",
@ -41,6 +45,7 @@
"@angular-devkit/build-angular": "^17.0.8",
"@angular/cli": "^17.0.8",
"@angular/compiler-cli": "^17.0.8",
"@types/chartjs-plugin-colorschemes": "^0.4.5",
"@types/jasmine": "~3.10.0",
"@types/leaflet": "^1.7.8",
"@types/node": "^20.10.5",
@ -52,4 +57,4 @@
"karma-jasmine-html-reporter": "~1.7.0",
"typescript": "~5.2.2"
}
}
}

View File

@ -0,0 +1,4 @@
declare module 'chartjs-plugin-colorschemes-v3/src/colorschemes/colorschemes.office';
declare module 'chartjs-plugin-colorschemes-v3/src/colorschemes/colorschemes.office'{
export function Aspect6(): Function
}

View File

@ -0,0 +1 @@
<p-chart [type]="type" [data]="data" [options]="options"></p-chart>

View File

@ -0,0 +1,5 @@
@media (max-width: 600px) {
.chartjs-render-monitor {
height: 700px !important;
}
}

View File

@ -0,0 +1,74 @@
import { Component, OnInit, Input } from '@angular/core';
import 'chartjs-plugin-colorschemes-v3/src/plugins/plugin.colorschemes';
import { Aspect6 } from 'chartjs-plugin-colorschemes-v3/src/colorschemes/colorschemes.office';
@Component({
selector: 'chart',
templateUrl: './chart.component.html',
styleUrls: ['./chart.component.scss']
})
export class ChartComponent implements OnInit {
@Input() type = "";
@Input() data: any = {};
options: any = {};
constructor() { }
ngOnInit(): void {
const documentStyle = getComputedStyle(document.documentElement);
const textColor = documentStyle.getPropertyValue('--text-color');
this.options = {
responsive: true,
maintainAspectRatio: false,
legend: {
position: 'bottom'
},
scales: { },
plugins: {
legend: {
labels: {
usePointStyle: true,
color: textColor
}
},
colorschemes: {
scheme: Aspect6
}
}
};
if (this.type === "bar") {
const textColorSecondary = documentStyle.getPropertyValue('--text-color-secondary');
const surfaceBorder = documentStyle.getPropertyValue('--surface-border');
this.options.scales = {
y: {
beginAtZero: true,
ticks: {
color: textColorSecondary
},
grid: {
color: surfaceBorder,
drawBorder: false
}
},
x: {
ticks: {
color: textColorSecondary
},
grid: {
color: surfaceBorder,
drawBorder: false
}
}
};
} else if (this.type === "horizontal-bar") {
this.options.indexAxis = 'y';
this.type = "bar";
} else if (this.type === "doughnut") {
this.options.cutout = '60%';
}
}
}

View File

@ -0,0 +1,28 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { TranslationModule } from '../../translation.module';
import { ButtonModule } from 'primeng/button';
import { ChartModule as OrigChartModule } from 'primeng/chart';
import { ChartComponent } from './chart.component';
@NgModule({
declarations: [
ChartComponent
],
imports: [
CommonModule,
FormsModule,
BsDatepickerModule.forRoot(),
TranslationModule,
ButtonModule,
OrigChartModule
],
exports: [
ChartComponent
]
})
export class ChartModule { }

View File

@ -0,0 +1,6 @@
<div class="input-group flex-nowrap">
<span class="input-group-text" id="addon-wrapping">Filtra interventi per data</span>
<input type="text" [disabled]="disabled" placeholder="Seleziona un intervallo di date" class="form-control" placement="bottom"
bsDaterangepicker [bsConfig]="dateRangePickerOptions" [maxDate]="maxDate" [(bsValue)]="range" (bsValueChange)="updateValue($event)">
<button class="btn btn-outline-secondary" type="button" (click)="resetRange()">Rimuovi filtri</button>
</div>

View File

@ -0,0 +1,3 @@
.date-picker {
width: 85%
}

View File

@ -0,0 +1,102 @@
import { Component, OnInit, forwardRef } from '@angular/core';
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
import { defineLocale } from 'ngx-bootstrap/chronos';
import { BsLocaleService } from 'ngx-bootstrap/datepicker';
import { itLocale } from 'ngx-bootstrap/locale';
defineLocale('it', itLocale);
@Component({
selector: 'daterange-picker',
templateUrl: './daterange-picker.component.html',
styleUrls: ['./daterange-picker.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: forwardRef(() => DaterangePickerComponent)
}
]
})
export class DaterangePickerComponent implements OnInit, ControlValueAccessor {
disabled = false;
maxDate: Date = new Date();
dateRangePickerOptions = {
ranges: [
{
value: [new Date(new Date().setDate(new Date().getDate() - 31)), new Date()],
label: 'Ultimi 31 giorni'
}, {
value: [new Date(new Date().setDate(new Date().getDate() - 7)), new Date()],
label: 'Ultimi 7 giorni'
}, {
value: [new Date(new Date().getFullYear(), 0, 1), new Date()],
label: 'Anno corrente'
}, {
value: [new Date(new Date().getFullYear() - 1, 0, 1), new Date(new Date().getFullYear() - 1, 11, 31)],
label: 'Anno precedente'
}, {
value: [new Date(new Date().setMonth(new Date().getMonth() - 6)), new Date()],
label: 'Ultimi 6 mesi'
}
]
};
range: (Date | undefined)[] | undefined = undefined;
constructor(private localeService: BsLocaleService) {
}
ngOnInit(): void {
this.localeService.use(window.navigator.language.split("-")[0]);
}
get value(): (Date | undefined)[] | undefined {
if(this.range === null) return undefined;
return this.range;
}
set value(range: (Date | undefined)[] | undefined) {
console.log("new value", range, "old value", this.range);
this.range = range;
this.onChange(this.range);
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
updateValue($event: (Date | undefined)[] | undefined) {
this.range = $event;
console.log("updateValue", this.range);
this.markAsTouched();
this.onChange($event);
}
resetRange() {
this.updateValue(undefined);
}
writeValue(range: (Date | undefined)[] | undefined): void {
this.range = range;
}
onChange = (value: (Date | undefined)[] | undefined) => {};
onTouched = () => {};
registerOnChange(fn: (value: (Date | undefined)[] | undefined) => void): void {
this.onChange = fn;
console.log(fn);
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
fn();
}
markAsTouched() {
this.onTouched();
}
}

View File

@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { TranslationModule } from '../../translation.module';
import { DaterangePickerComponent } from './daterange-picker.component';
@NgModule({
declarations: [
DaterangePickerComponent
],
imports: [
CommonModule,
FormsModule,
BsDatepickerModule.forRoot(),
TranslationModule
],
exports: [
DaterangePickerComponent
]
})
export class DaterangePickerModule { }

View File

@ -17,7 +17,7 @@ export class MapPickerComponent implements OnInit {
@Input() selectLat = "";
@Input() selectLng = "";
@Output() onMarkerSet = new EventEmitter<any>();
@Output() markerSet = new EventEmitter<any>();
options = {
layers: [
@ -48,7 +48,7 @@ export class MapPickerComponent implements OnInit {
}
setMarker(latLng: LatLng) {
this.onMarkerSet.emit({
this.markerSet.emit({
lat: latLng.lat,
lng: latLng.lng
});

View File

@ -0,0 +1,2 @@
<div id="map" style="height: 300px;" leaflet [leafletOptions]="mapOptions" [leafletLayersControl]="mapLayersControl" [leafletLayers]="mapLayers" [leafletFitBounds]="mapFitBounds" (leafletClick)="_mapClick($event)">
</div>

View File

@ -0,0 +1,73 @@
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { LatLngTuple, LatLngBounds, latLng, tileLayer, Marker, Layer } from 'leaflet';
export declare class LeafletControlLayersConfig {
baseLayers: {
[name: string]: Layer;
};
overlays: {
[name: string]: Layer;
};
}
interface IRange {
value: Date[];
label: string;
}
@Component({
selector: 'map',
templateUrl: './map.component.html',
styleUrls: ['./map.component.scss']
})
export class MapComponent implements OnInit {
@Output() mapClick = new EventEmitter<any>();
defaultLat = 45.88283872530;
defaultLng = 10.18226623535;
mapOptions = {
zoom: 10,
center: latLng(this.defaultLat, this.defaultLng)
}
mapLayersControl: LeafletControlLayersConfig = {
baseLayers: {
'Open Street Map': tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }),
'ESRI WorldImagery': tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' }),
'ESRI WorldTopoMap': tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community' }),
'Open Topo Map': tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)' }),
'Open Street Map Hot': tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', { maxZoom: 20, attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/" target="_blank">Humanitarian OpenStreetMap Team</a> hosted by <a href="https://openstreetmap.fr/" target="_blank">OpenStreetMap France</a>' }),
'Stadia Dark': tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', { maxZoom: 20, attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }),
},
overlays: {
'Open Railway Map': tileLayer('https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', { maxZoom: 19, attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | Map style: &copy; <a href="https://www.OpenRailwayMap.org">OpenRailwayMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)' }),
'Open Fire Map': tileLayer('http://openfiremap.org/hytiles/{z}/{x}/{y}.png', { maxZoom: 19, attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | Map style: &copy; <a href="http://www.openfiremap.org">OpenFireMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)' }),
'SafeCast': tileLayer('https://s3.amazonaws.com/te512.safecast.org/{z}/{x}/{y}.png', { maxZoom: 16, attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | Map style: &copy; <a href="https://blog.safecast.org/about/">SafeCast</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)' })
}
};
mapLayers: Layer[] = [this.mapLayersControl.baseLayers['Open Street Map']];
mapFitBounds: LatLngBounds = latLng(this.defaultLat, this.defaultLng).toBounds(3000);
constructor() { }
ngOnInit(): void { }
_mapClick(event: any) {
this.mapClick.emit(event);
}
setBounds(boundsTuple: LatLngTuple[] | undefined) {
if(boundsTuple && boundsTuple.length > 0) {
this.mapFitBounds = new LatLngBounds(boundsTuple);
} else {
this.mapFitBounds = latLng(this.defaultLat, this.defaultLng).toBounds(3000);
}
}
addMarker(marker: Marker) {
this.mapLayers.push(marker);
}
removeAllMarkers() {
this.mapLayers.splice(1);
}
}

View File

@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LeafletModule } from '@asymmetrik/ngx-leaflet';
import { TranslationModule } from '../../translation.module';
import { MapComponent } from './map.component';
@NgModule({
declarations: [
MapComponent
],
imports: [
CommonModule,
FormsModule,
LeafletModule,
TranslationModule
],
exports: [
MapComponent
]
})
export class MapModule { }

View File

@ -65,8 +65,8 @@
</div>
<div [class.is-invalid-div]="!isFieldValid('lat')" class="mb-2">
<label>{{ 'place'|translate|titlecase }}</label>
<map-picker *ngIf="addingService" (onMarkerSet)="setPlace($event.lat, $event.lng)"></map-picker>
<map-picker *ngIf="!addingService && loadedServiceLat !== ''" (onMarkerSet)="setPlace($event.lat, $event.lng)" [selectLat]="loadedServiceLat" [selectLng]="loadedServiceLng"></map-picker>
<map-picker *ngIf="addingService" (markerSet)="setPlace($event.lat, $event.lng)"></map-picker>
<map-picker *ngIf="!addingService && loadedServiceLat !== ''" (markerSet)="setPlace($event.lat, $event.lng)" [selectLat]="loadedServiceLat" [selectLng]="loadedServiceLng"></map-picker>
</div>
<div class="form-group">
<label for="notes">{{ 'notes'|translate|titlecase }}</label><br>

View File

@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { StatsServicesComponent } from './stats-services/stats-services.component';
const routes: Routes = [
{ path: 'services', component: StatsServicesComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class StatsRoutingModule { }

View File

@ -0,0 +1,7 @@
<daterange-picker [(ngModel)]="range" (ngModelChange)="filterDateRangeChanged($event)"></daterange-picker>
<h3>Mappa degli interventi</h3>
<map #servicesMap (mapClick)="serviceMapClick($event)"></map>
<h3 class="mt-3">Interventi per vigile</h3>
<chart type="pie" [data]="data"></chart>

View File

@ -0,0 +1,81 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { TranslateService } from '@ngx-translate/core';
import { ApiClientService } from 'src/app/_services/api-client.service';
import { MapComponent } from 'src/app/_components/map/map.component';
import { LatLngTuple, LatLngBounds, Marker } from 'leaflet';
@Component({
selector: 'app-stats',
templateUrl: './stats-services.component.html',
styleUrls: ['./stats-services.component.scss']
})
export class StatsServicesComponent implements OnInit {
services: any[] = [];
servicesFilterStart: Date | undefined;
servicesFilterEnd: Date | undefined;
data: any;
@ViewChild("servicesMap") servicesMap!: MapComponent;
range: (Date | undefined)[] | undefined = undefined;
constructor(
private toastr: ToastrService,
private api: ApiClientService,
private translate: TranslateService
) { }
ngOnInit(): void {
this.loadServices();
this.data = {
labels: ['A', 'B', 'C'],
datasets: [
{
data: [540, 325, 702]
}
]
};
}
loadServices() {
this.api.get("stats/services", {
from: this.servicesFilterStart ? this.servicesFilterStart.toISOString() : undefined,
to: this.servicesFilterEnd ? this.servicesFilterEnd.toISOString() : undefined
}).then((response: any) => {
this.services = response;
console.log(this.services);
let serviceMapFitBoundsTuple = [];
this.servicesMap.removeAllMarkers();
for (let service of this.services) {
const pos: LatLngTuple = [service.place.lat, service.place.lon];
serviceMapFitBoundsTuple.push(pos);
let marker = new Marker(pos);
this.servicesMap.addMarker(marker);
}
this.servicesMap.setBounds(serviceMapFitBoundsTuple);
}).catch((error: any) => {
console.error(error);
this.toastr.error(this.translate.instant("Error while loading services"));
});
}
serviceMapClick(e: any) {
console.log(e);
}
filterDateRangeChanged($event: Date[]) {
console.log($event);
if ($event === undefined) {
this.servicesFilterStart = undefined;
this.servicesFilterEnd = undefined;
} else {
this.servicesFilterStart = $event[0];
this.servicesFilterEnd = $event[1];
}
this.loadServices();
}
}

View File

@ -0,0 +1,34 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { DatetimePickerModule } from '../../_components/datetime-picker/datetime-picker.module';
import { DaterangePickerModule } from '../../_components/daterange-picker/daterange-picker.module';
import { BackBtnModule } from '../../_components/back-btn/back-btn.module';
import { MapModule } from 'src/app/_components/map/map.module';
import { ChartModule } from '../../_components/chart/chart.module';
import { TranslationModule } from '../../translation.module';
import { StatsRoutingModule } from './stats-routing.module';
import { StatsServicesComponent } from './stats-services/stats-services.component';
@NgModule({
declarations: [
StatsServicesComponent
],
imports: [
CommonModule,
StatsRoutingModule,
FormsModule,
ReactiveFormsModule,
BsDatepickerModule.forRoot(),
DatetimePickerModule,
DaterangePickerModule,
BackBtnModule,
MapModule,
ChartModule,
TranslationModule
]
})
export class StatsModule { }

View File

@ -29,6 +29,11 @@ const routes: Routes = [
loadChildren: () => import('./_routes/edit-training/edit-training.module').then(m => m.EditTrainingModule),
canActivate: [AuthorizeGuard]
},
{
path: 'stats',
loadChildren: () => import('./_routes/stats/stats.module').then(m => m.StatsModule),
canActivate: [AuthorizeGuard]
},
{ path: "login/:redirect/:extraParam", component: LoginComponent },
{ path: "login/:redirect", component: LoginComponent },
//

View File

@ -18,3 +18,7 @@
color: green;
font-size: 1.5em;
}
.leaflet-pane.leaflet-shadow-pane {
display: none;
}