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\ScheduleSlotsController;
use App\Http\Controllers\AvailabilityController;
use App\Http\Controllers\AlertController;
use App\Http\Controllers\LogsController;
use App\Http\Controllers\TelegramController;
use App\Http\Controllers\ServiceController;
@ -47,6 +48,11 @@ Route::middleware('auth:sanctum')->group( function () {
Route::post('/availability', [AvailabilityController::class, 'updateAvailability']);
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::post('/services', [ServiceController::class, 'createOrUpdate']);
Route::get('/services/{id}', [ServiceController::class, 'show']);

View File

@ -10,6 +10,9 @@
</div>
</div>
<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">
<thead>
<tr>
@ -18,23 +21,27 @@
</tr>
</thead>
<tbody>
<ng-container *ngFor="let user of users">
<tr *ngIf="alertEnabled || user.response !== 'waiting'">
<ng-container *ngFor="let crewUser of crewUsers">
<tr>
<td>
<img alt="red helmet" src="./assets/icons/red_helmet.png" width="20px" *ngIf="user.chief">
<img alt="red helmet" src="./assets/icons/black_helmet.png" width="20px" *ngIf="!user.chief">
{{ user.name }}
<img alt="driver" src="./assets/icons/wheel.png" width="20px" *ngIf="user.driver">
<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="!crewUser.user.chief">
{{ crewUser.user.name }}
<img alt="driver" src="./assets/icons/wheel.png" width="20px" *ngIf="crewUser.user.driver">
</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>In attesa di risposta</td>
</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>Presente</td>
</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>Non presente</td>
</ng-container>
@ -42,28 +49,14 @@
</ng-container>
</tbody>
</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">
<label for="details" class="form-label">Dettagli allerta</label>
<textarea class="form-control" id="details" rows="3" [(ngModel)]="notes"></textarea>
<button class="btn btn-secondary mt-2" (click)="saveAlertSettings()">Salva</button>
</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 class="well well-lg card card-block card-header">
<label for="details" class="form-label">Dettagli allerta</label>
<textarea class="form-control" id="details" rows="3"
[(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 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>
</div>

View File

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

View File

@ -18,12 +18,12 @@
</button>
</div>
<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">
<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
</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 🧯
</button>
</div>

View File

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

View File

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

View File

@ -31,6 +31,13 @@
"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"
},
"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": {
"username": "username",
"password": "password",

View File

@ -31,6 +31,13 @@
"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"
},
"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": {
"username": "username",
"password": "password",