WIP support for Alerts

This commit is contained in:
Matteo Gheza 2023-10-22 14:22:12 +02:00
parent 06af68cb61
commit 14183e390c
13 changed files with 419 additions and 97 deletions

View File

@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers;
use App\Models\Alert;
use App\Models\AlertCrew;
use Illuminate\Http\Request;
use App\Models\User;
class AlertController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return response()->json(
Alert::with('crew.user')
->where('closed', false)
->orderBy('created_at', 'desc')
->get()
);
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$type = $request->input('type', 'support');
$ignoreWarning = $request->input('ignoreWarning', false);
//Count users when not hidden and available
$count = User::where('hidden', false)->where('available', true)->count();
if($count == 0) {
return response()->json([
'status' => 'error',
'message' => 'Nessun utente disponibile.',
'ignorable' => false,
], 400);
}
//Check if there is at least one chief available (not hidden)
$chiefCount = User::where([
['hidden', '=', false],
['available', '=', true],
['chief', '=', true]
])->count();
if($chiefCount == 0 && !$ignoreWarning) {
return response()->json([
'status' => 'error',
'message' => 'Nessun caposquadra disponibile. Sei sicuro di voler proseguire?',
'ignorable' => true,
], 400);
}
//Check if there is at least one driver available (not hidden)
$driverCount = User::where([
['hidden', '=', false],
['available', '=', true],
['driver', '=', true]
])->count();
if($driverCount == 0 && !$ignoreWarning) {
return response()->json([
'status' => 'error',
'message' => 'Nessun autista disponibile. Sei sicuro di voler proseguire?',
'ignorable' => true,
], 400);
}
//Select call list (everyone not hidden and available)
$users = User::where('hidden', false)->where('available', true)->get();
if(count($users) < 5 && $type = "full") $type = "support";
//Create alert
$alert = new Alert;
$alert->type = $type;
$alert->addedBy()->associate(auth()->user());
$alert->updatedBy()->associate(auth()->user());
$alert->save();
//Create alert crew
$alertCrewIds = [];
foreach($users as $user) {
$alertCrew = new AlertCrew();
$alertCrew->user_id = $user->id;
$alertCrew->save();
$alertCrewIds[] = $alertCrew->id;
}
$alert->crew()->attach($alertCrewIds);
$alert->save();
//Return response
return response()->json([
'status' => 'success',
'alert' => $alert,
], 200);
}
/**
* Get single Alert
*/
public function show(Request $request, $id)
{
User::where('id', $request->user()->id)->update(['last_access' => now()]);
return response()->json(
Alert::with('crew.user')->find($id)
);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Alert $Alert)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, $id)
{
//TODO: improve permissions and roles
if(!$request->user()->hasPermission("users-read")) abort(401);
$alert = Alert::find($id);
$alert->notes = $request->input('notes', $alert->notes);
$alert->closed = $request->input('closed', $alert->closed);
$alert->updatedBy()->associate(auth()->user());
$alert->save();
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Alert $Alert)
{
//
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Alert extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'type',
'closed',
'notes'
];
public function crew(): BelongsToMany
{
return $this->belongsToMany(
AlertCrew::class,
'alerts_crew_associations'
);
}
/**
* Get the user.
*/
public function addedBy(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the user.
*/
public function updatedBy(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AlertCrew extends Model
{
use HasFactory;
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'accepted'
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'respondedAt' => 'datetime'
];
/**
* Get the user.
*/
public function user(): BelongsTo {
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('alerts', function (Blueprint $table) {
$table->id();
$table->string('type');
$table->boolean('closed')->default(false);
$table->text('notes')->nullable();
$table->foreignId('added_by_id')->constrained('users');
$table->foreignId('updated_by_id')->constrained('users');
$table->timestamps();
});
Schema::create('alert_crews', function (Blueprint $table) {
$table->id();
$table->boolean('accepted')->nullable();
$table->foreignId('user_id')->constrained('users');
$table->dateTime('responded_at')->nullable();
});
Schema::create('alerts_crew_associations', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('alert_crew_id')->unsigned();
$table->foreign('alert_crew_id')
->references('id')
->on('alert_crews')->onDelete('cascade');
$table->unsignedBigInteger('alert_id')->unsigned();
$table->foreign('alert_id')
->references('id')
->on('alerts')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('alerts_crew_associations');
Schema::dropIfExists('alerts');
Schema::dropIfExists('alert_crews');
}
};

View File

@ -5,6 +5,7 @@ use App\Http\Controllers\AuthController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\ScheduleSlotsController; use App\Http\Controllers\ScheduleSlotsController;
use App\Http\Controllers\AvailabilityController; use App\Http\Controllers\AvailabilityController;
use App\Http\Controllers\AlertController;
use App\Http\Controllers\LogsController; use App\Http\Controllers\LogsController;
use App\Http\Controllers\TelegramController; use App\Http\Controllers\TelegramController;
use App\Http\Controllers\ServiceController; use App\Http\Controllers\ServiceController;
@ -47,6 +48,11 @@ Route::middleware('auth:sanctum')->group( function () {
Route::post('/availability', [AvailabilityController::class, 'updateAvailability']); Route::post('/availability', [AvailabilityController::class, 'updateAvailability']);
Route::post('/manual_mode', [AvailabilityController::class, 'updateAvailabilityManualMode']); Route::post('/manual_mode', [AvailabilityController::class, 'updateAvailabilityManualMode']);
Route::get('/alerts', [AlertController::class, 'index']);
Route::post('/alerts', [AlertController::class, 'store']);
Route::get('/alerts/{id}', [AlertController::class, 'show']);
Route::patch('/alerts/{id}', [AlertController::class, 'update']);
Route::get('/services', [ServiceController::class, 'index']); Route::get('/services', [ServiceController::class, 'index']);
Route::post('/services', [ServiceController::class, 'createOrUpdate']); Route::post('/services', [ServiceController::class, 'createOrUpdate']);
Route::get('/services/{id}', [ServiceController::class, 'show']); Route::get('/services/{id}', [ServiceController::class, 'show']);

View File

@ -10,6 +10,9 @@
</div> </div>
</div> </div>
<div class="modal-body" *ngIf="id !== 0"> <div class="modal-body" *ngIf="id !== 0">
<div class="alert alert-danger" role="alert" *ngIf="alertClosed">
<i class="fas fa-exclamation-triangle"></i> Allerta conclusa
</div>
<table class="table table-border table-striped w-100"> <table class="table table-border table-striped w-100">
<thead> <thead>
<tr> <tr>
@ -18,23 +21,27 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<ng-container *ngFor="let user of users"> <ng-container *ngFor="let crewUser of crewUsers">
<tr *ngIf="alertEnabled || user.response !== 'waiting'"> <tr>
<td> <td>
<img alt="red helmet" src="./assets/icons/red_helmet.png" width="20px" *ngIf="user.chief"> <img alt="red helmet" src="./assets/icons/red_helmet.png" width="20px" *ngIf="crewUser.user.chief">
<img alt="red helmet" src="./assets/icons/black_helmet.png" width="20px" *ngIf="!user.chief"> <img alt="red helmet" src="./assets/icons/black_helmet.png" width="20px" *ngIf="!crewUser.user.chief">
{{ user.name }} {{ crewUser.user.name }}
<img alt="driver" src="./assets/icons/wheel.png" width="20px" *ngIf="user.driver"> <img alt="driver" src="./assets/icons/wheel.png" width="20px" *ngIf="crewUser.user.driver">
</td> </td>
<ng-container *ngIf="user.response == 'waiting'"> <ng-container *ngIf="crewUser.accepted == null && alertClosed">
<td style="width: 1px;"><i class="fa fa-times" style="color:red"></i></td>
<td>Nessuna risposta</td>
</ng-container>
<ng-container *ngIf="crewUser.accepted == null && !alertClosed">
<td style="width: 1px;"><i class="fas fa-spinner fa-spin"></i></td> <td style="width: 1px;"><i class="fas fa-spinner fa-spin"></i></td>
<td>In attesa di risposta</td> <td>In attesa di risposta</td>
</ng-container> </ng-container>
<ng-container *ngIf="user.response == true"> <ng-container *ngIf="crewUser.accepted == true">
<td style="width: 1px;"><i class="fa fa-check" style="color:green"></i></td> <td style="width: 1px;"><i class="fa fa-check" style="color:green"></i></td>
<td>Presente</td> <td>Presente</td>
</ng-container> </ng-container>
<ng-container *ngIf="user.response == false"> <ng-container *ngIf="crewUser.accepted == false">
<td style="width: 1px;"><i class="fa fa-times" style="color:red"></i></td> <td style="width: 1px;"><i class="fa fa-times" style="color:red"></i></td>
<td>Non presente</td> <td>Non presente</td>
</ng-container> </ng-container>
@ -42,28 +49,14 @@
</ng-container> </ng-container>
</tbody> </tbody>
</table> </table>
<ng-container *ngIf="auth.profile.can('users-read') && alertEnabled">
<button type="button" class="btn btn-primary mb-2" (click)="isAdvancedCollapsed = !isAdvancedCollapsed"
[attr.aria-expanded]="!isAdvancedCollapsed" aria-controls="collapseBasic">
<ng-container *ngIf="isAdvancedCollapsed">Mostra impostazioni avanzate</ng-container>
<ng-container *ngIf="!isAdvancedCollapsed">Nascondi impostazioni avanzate</ng-container>
</button>
<div [collapse]="isAdvancedCollapsed" [isAnimated]="true">
<div class="well well-lg card card-block card-header"> <div class="well well-lg card card-block card-header">
<label for="details" class="form-label">Dettagli allerta</label> <label for="details" class="form-label">Dettagli allerta</label>
<textarea class="form-control" id="details" rows="3" [(ngModel)]="notes"></textarea> <textarea class="form-control" id="details" rows="3"
<button class="btn btn-secondary mt-2" (click)="saveAlertSettings()">Salva</button> [(ngModel)]="notes" [disabled]="!auth.profile.can('users-read') || alertClosed==1" (keyup)="notesUpdated()"></textarea>
<button class="btn btn-secondary mt-2" (click)="saveAlertSettings()" [disabled]="!auth.profile.can('users-read') || !notesHasUnsavedChanges || alertClosed">Salva</button>
</div> </div>
</div> </div>
</ng-container>
<ng-container *ngIf="(!auth.profile.can('users-read') && notes !== '') || !alertEnabled">
<div class="well well-lg card card-block card-header">
<h5>Dettagli allerta</h5>
<h2>{{ notes }}</h2>
</div>
</ng-container>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" (click)="deleteAlert()" *ngIf="auth.profile.can('users-read') && alertEnabled">Rimuovi allerta corrente <i class="fas fa-exclamation-triangle"></i></button> <button type="button" class="btn btn-danger" (click)="deleteAlert()" *ngIf="auth.profile.can('users-read') && !alertClosed">Rimuovi allerta corrente <i class="fas fa-exclamation-triangle"></i></button>
<button type="button" class="btn btn-secondary" (click)="bsModalRef.hide()">{{ 'close'|translate }}</button> <button type="button" class="btn btn-secondary" (click)="bsModalRef.hide()">{{ 'close'|translate }}</button>
</div> </div>

View File

@ -3,6 +3,7 @@ import { BsModalRef } from 'ngx-bootstrap/modal';
import { ApiClientService } from 'src/app/_services/api-client.service'; import { ApiClientService } from 'src/app/_services/api-client.service';
import { AuthService } from 'src/app/_services/auth.service'; import { AuthService } from 'src/app/_services/auth.service';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { TranslateService } from '@ngx-translate/core';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
const isEqual = (...objects: any[]) => objects.every(obj => JSON.stringify(obj) === JSON.stringify(objects[0])); const isEqual = (...objects: any[]) => objects.every(obj => JSON.stringify(obj) === JSON.stringify(objects[0]));
@ -15,28 +16,35 @@ const isEqual = (...objects: any[]) => objects.every(obj => JSON.stringify(obj)
export class ModalAlertComponent implements OnInit, OnDestroy { export class ModalAlertComponent implements OnInit, OnDestroy {
id = 0; id = 0;
users: any[] = []; crewUsers: any[] = [];
isAdvancedCollapsed = true;
loadDataInterval: NodeJS.Timer | undefined = undefined; loadDataInterval: NodeJS.Timer | undefined = undefined;
notes = ""; notes = "";
originalNotes = "";
notesHasUnsavedChanges = false;
alertEnabled = true; alertClosed = 0;
constructor( constructor(
public bsModalRef: BsModalRef, public bsModalRef: BsModalRef,
private api: ApiClientService, private api: ApiClientService,
public auth: AuthService, public auth: AuthService,
private toastr: ToastrService private toastr: ToastrService,
private translate: TranslateService
) { } ) { }
loadResponsesData() { loadResponsesData() {
//TODO: do not update data if not changed. Support for content hash in response header?
this.api.get(`alerts/${this.id}`).then((response) => { this.api.get(`alerts/${this.id}`).then((response) => {
if(this.alertEnabled !== response.enabled) this.alertEnabled = response.enabled; console.log(response, this.alertClosed, response.closed);
if(!isEqual(this.users, response.crew)) this.users = response.crew; if(this.alertClosed !== response.closed) this.alertClosed = response.closed;
if (this.notes === "" || this.notes === null) { if(!isEqual(this.crewUsers, response.crew)) this.crewUsers = response.crew;
if(!isEqual(this.notes, response.notes)) this.notes = response.notes; if (!this.notesHasUnsavedChanges) {
if(this.notes !== response.notes) {
this.notes = response.notes;
this.originalNotes = response.notes;
}
} }
}); });
} }
@ -59,43 +67,58 @@ export class ModalAlertComponent implements OnInit, OnDestroy {
} }
} }
notesUpdated() {
this.notesHasUnsavedChanges = this.notes !== this.originalNotes;
}
saveAlertSettings() { saveAlertSettings() {
if(!this.auth.profile.can('users-read')) return; if(!this.auth.profile.can('users-read')) return;
this.api.post(`alerts/${this.id}/settings`, { this.api.patch(`alerts/${this.id}`, {
notes: this.notes notes: this.notes
}).then((response) => { }).then((response) => {
this.toastr.success("Impostazioni salvate con successo"); this.translate.get('alert.settings_updated_successfully').subscribe((res: string) => {
this.toastr.success(res);
});
this.notesHasUnsavedChanges = false;
this.originalNotes = this.notes;
}); });
} }
deleteAlert() { deleteAlert() {
if(!this.auth.profile.can('users-read')) return; if(!this.auth.profile.can('users-read')) return;
this.translate.get([
'alert.delete_confirm_title',
'alert.delete_confirm_text',
'table.yes_remove',
'table.cancel',
'alert.deleted_successfully',
'alert.delete_failed'
]).subscribe((res: any) => {
console.log(res);
Swal.fire({ Swal.fire({
title: "Sei sicuro di voler ritirare l'allarme?", title: res['alert.delete_confirm_title'],
text: "I vigili verranno avvisati dell'azione", text: res['alert.delete_confirm_text'],
icon: 'warning', icon: 'warning',
showCancelButton: true, showCancelButton: true,
confirmButtonColor: '#3085d6', confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33', cancelButtonColor: '#d33',
confirmButtonText: "Si, rimuovi", confirmButtonText: res['table.yes_remove'],
cancelButtonText: "Annulla" cancelButtonText: res['table.cancel']
}).then((result: any) => { }).then((result: any) => {
if (result.isConfirmed) { if (result.isConfirmed) {
this.api.delete(`alerts/${this.id}`).then((response) => { this.api.patch(`alerts/${this.id}`, {
closed: true
}).then((response) => {
console.log(response); console.log(response);
this.bsModalRef.hide(); this.bsModalRef.hide();
this.api.alertsChanged.next(); this.api.alertsChanged.next();
/* this.toastr.success(res['alert.deleted_successfully']);
this.translate.get('table.service_deleted_successfully').subscribe((res: string) => { this.api.alertsChanged.next();
this.toastr.success(res);
});
this.loadTableData();
}).catch((e) => { }).catch((e) => {
this.translate.get('table.service_deleted_error').subscribe((res: string) => { this.toastr.error(res['alert.delete_failed']);
this.toastr.error(res);
*/
}); });
} }
}); });
});
} }
} }

View File

@ -18,12 +18,12 @@
</button> </button>
</div> </div>
<owner-image></owner-image> <owner-image></owner-image>
<div class="text-center" *ngIf="false && auth.profile.can('users-read')"> <div class="text-center" *ngIf="auth.profile.can('users-read')">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button type="button" class="btn btn-danger" (click)="addAlertFull()" [disabled]="!api.availableUsers || api.availableUsers! < 5 || alertLoading"> <button type="button" class="btn btn-danger" (click)="addAlert('full')" [disabled]="!api.availableUsers || api.availableUsers! < 5 || alertLoading">
🚒 Richiedi squadra completa 🚒 Richiedi squadra completa
</button> </button>
<button type="button" class="btn btn-warning" (click)="addAlertSupport()" [disabled]="!api.availableUsers || api.availableUsers! < 2 || alertLoading"> <button type="button" class="btn btn-warning" (click)="addAlert('support')" [disabled]="!api.availableUsers || api.availableUsers! < 2 || alertLoading">
Richiedi squadra di supporto 🧯 Richiedi squadra di supporto 🧯
</button> </button>
</div> </div>

View File

@ -84,47 +84,34 @@ export class ListComponent implements OnInit, OnDestroy {
this.scheduleModalRef = this.modalService.show(ModalAvailabilityScheduleComponent, Object.assign({}, { class: 'modal-custom' })); this.scheduleModalRef = this.modalService.show(ModalAvailabilityScheduleComponent, Object.assign({}, { class: 'modal-custom' }));
} }
addAlertFull() { addAlert(type: string, ignoreWarning = false) {
this.alertLoading = true; this.alertLoading = true;
if(!this.auth.profile.can('users-read')) return; if(!this.auth.profile.can('users-read')) return;
this.api.post("alerts", { this.api.post("alerts", {
type: "full" type,
ignoreWarning
}).then((response) => { }).then((response) => {
console.log(response);
this.alertLoading = false; this.alertLoading = false;
if(response?.status === "error") {
this.toastr.error(response.message, undefined, {
timeOut: 5000
});
return;
}
this.alertModalRef = this.modalService.show(ModalAlertComponent, { this.alertModalRef = this.modalService.show(ModalAlertComponent, {
initialState: { initialState: {
id: response.id id: response.alert.id
} }
}); });
this.api.alertsChanged.next(); this.api.alertsChanged.next();
}); }).catch((err) => {
} console.log(err);
addAlertSupport() {
this.alertLoading = true;
if(!this.auth.profile.can('users-read')) return;
this.api.post("alerts", {
type: "support"
}).then((response) => {
this.alertLoading = false; this.alertLoading = false;
if(response?.status === "error") { if(err.error?.ignorable === true) {
this.toastr.error(response.message, undefined, { if(confirm(err.error.message)) {
timeOut: 5000 this.addAlert(type, true);
}); }
return; return;
} }
this.alertModalRef = this.modalService.show(ModalAlertComponent, { if(err.error?.message === undefined) err.error.message = "Errore sconosciuto";
initialState: { this.toastr.error(err.error.message, undefined, {
id: response.id timeOut: 5000
}
}); });
this.api.alertsChanged.next();
}); });
} }

View File

@ -49,6 +49,15 @@ export class ApiClientService {
}); });
} }
public patch(endpoint: string, data: any = {}) {
return new Promise<any>((resolve, reject) => {
this.http.patch(this.apiEndpoint(endpoint), data).subscribe({
next: (v) => resolve(v),
error: (e) => reject(e)
});
});
}
public delete(endpoint: string) { public delete(endpoint: string) {
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
this.http.delete(this.apiEndpoint(endpoint)).subscribe({ this.http.delete(this.apiEndpoint(endpoint)).subscribe({

View File

@ -52,7 +52,6 @@ export class AppComponent {
} }
}); });
/*
this.loadAlertsInterval = setInterval(() => { this.loadAlertsInterval = setInterval(() => {
console.log("Refreshing alerts..."); console.log("Refreshing alerts...");
this.loadAlerts(); this.loadAlerts();
@ -62,7 +61,6 @@ export class AppComponent {
this.api.alertsChanged.subscribe(() => { this.api.alertsChanged.subscribe(() => {
this.loadAlerts(); this.loadAlerts();
}); });
*/
} }
openAlert(id: number) { openAlert(id: number) {

View File

@ -31,6 +31,13 @@
"manual_mode_update_failed": "Manual mode could not be updated. Please try again", "manual_mode_update_failed": "Manual mode could not be updated. Please try again",
"telegram_bot_token_request_failed": "Telegram bot token could not be generated. Please try again" "telegram_bot_token_request_failed": "Telegram bot token could not be generated. Please try again"
}, },
"alert": {
"delete_confirm_title": "Are you sure you want to remove this alert?",
"delete_confirm_text": "This action cannot be undone.",
"deleted_successfully": "Alert removed successfully",
"delete_failed": "Alert could not be removed. Please try again",
"settings_updated_successfully": "Settings updated successfully"
},
"login": { "login": {
"username": "username", "username": "username",
"password": "password", "password": "password",

View File

@ -31,6 +31,13 @@
"manual_mode_update_failed": "Errore durante l'aggiornamento della modalità manuale. Riprova più tardi", "manual_mode_update_failed": "Errore durante l'aggiornamento della modalità manuale. Riprova più tardi",
"telegram_bot_token_request_failed": "Errore durante la richiesta del token del bot Telegram. Riprova più tardi" "telegram_bot_token_request_failed": "Errore durante la richiesta del token del bot Telegram. Riprova più tardi"
}, },
"alert": {
"delete_confirm_title": "Sei sicuro di voler rimuovere questa allerta?",
"delete_confirm_text": "I vigili saranno avvisati della rimozione.",
"deleted_successfully": "Allerta rimossa con successo",
"delete_failed": "L'eliminazione dell'allerta è fallita. Riprova più tardi",
"settings_updated_successfully": "Impostazioni aggiornate con successo"
},
"login": { "login": {
"username": "username", "username": "username",
"password": "password", "password": "password",