Add support for trainings and small fixes

This commit is contained in:
Matteo Gheza 2023-09-07 14:01:54 +02:00
parent 3f7be4beb8
commit 1f61d2e96a
19 changed files with 570 additions and 24 deletions

View File

@ -118,7 +118,7 @@ class ServiceController extends Controller
$service->chief()->associate($request->chief);
$service->type()->associate($request->type);
$service->notes = $request->notes;
$service->start = $request->start/1000; //TODO: fix client-side
$service->start = $request->start/1000;
$service->end = $request->end/1000;
$service->place()->associate($place);
$service->addedBy()->associate($request->user());

View File

@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Models\Training;
use App\Models\User;
use Illuminate\Http\Request;
use App\Utils\Logger;
class TrainingController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
User::where('id', $request->user()->id)->update(['last_access' => now()]);
return response()->json(
Training::join('users', 'users.id', '=', 'chief_id')
->select('trainings.*', 'users.name as chief')
->with('crew:name')
->orderBy('start', 'desc')
->get()
);
}
/**
* Get single Training
*/
public function show(Request $request, $id)
{
User::where('id', $request->user()->id)->update(['last_access' => now()]);
return response()->json(
Training::join('users', 'users.id', '=', 'chief_id')
->select('trainings.*', 'users.name as chief')
->with('crew:name')
->find($id)
);
}
private function extractTrainingUsers($training)
{
$usersList = [$training->chief_id];
foreach($training->crew as $crew) {
$usersList[] = $crew->id;
}
return array_unique($usersList);
}
/**
* Add or update Training.
*/
public function createOrUpdate(Request $request)
{
$adding = !isset($request->id) || is_null($request->id);
$training = $adding ? new Training() : Training::where("id",$request->id)->with('crew')->first();
if(is_null($training)) abort(404);
if(!$adding) {
$usersToDecrement = $this->extractTrainingUsers($training);
User::whereIn('id', $usersToDecrement)->decrement('trainings');
$training->crew()->detach();
$training->save();
}
$training->name = $request->name;
$training->chief()->associate($request->chief);
$training->notes = $request->notes;
$training->start = $request->start/1000;
$training->end = $request->end/1000;
$training->place = $request->place;
$training->addedBy()->associate($request->user());
$training->updatedBy()->associate($request->user());
$training->save();
$training->crew()->attach(array_unique($request->crew));
$training->save();
$usersToIncrement = array_unique(array_merge(
[$request->chief],
$request->crew
));
User::whereIn('id', $usersToIncrement)->increment('trainings');
Logger::log($adding ? "Esercitazione aggiunta" : "Esercitazione modificata");
}
/**
* Remove the specified resource from storage.
*/
public function destroy($id)
{
$training = Training::find($id);
$usersToDecrement = $this->extractTrainingUsers($training);
User::whereIn('id', $usersToDecrement)->decrement('trainings');
$training->delete();
Logger::log("Esercitazione eliminata");
}
}

View File

@ -82,12 +82,4 @@ class Service extends Model
{
return $this->belongsTo(User::class);
}
/**
* Get the user that added the service.
*/
public function deletedBy(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,65 @@
<?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 Training extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'place',
'notes'
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'start' => 'datetime',
'end' => 'datetime'
];
public function chief(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function crew(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'trainings_crew',
'training_id',
'user_id'
);
}
/**
* Get the user that added the service.
*/
public function addedBy(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the user that added the service.
*/
public function updatedBy(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -51,7 +51,6 @@ return new class extends Migration
$table->dateTime('end');
$table->foreignId('added_by_id')->constrained('users');
$table->foreignId('updated_by_id')->constrained('users');
$table->foreignId('deleted_by_id')->nullable()->constrained('users');
$table->softDeletes();
$table->unique(['code']);
$table->timestamps();

View File

@ -0,0 +1,48 @@
<?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('trainings', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->dateTime('start');
$table->dateTime('end');
$table->foreignId('chief_id')->constrained('users');
$table->string('place');
$table->string('notes')->nullable();
$table->foreignId('added_by_id')->constrained('users');
$table->foreignId('updated_by_id')->constrained('users');
$table->unique(['name']);
$table->timestamps();
});
Schema::create('trainings_crew', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id')->unsigned();
$table->foreign('user_id')
->references('id')
->on('users')->onDelete('cascade');
$table->unsignedBigInteger('training_id')->unsigned();
$table->foreign('training_id')
->references('id')
->on('trainings')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('trainings');
}
};

View File

@ -10,6 +10,7 @@ use App\Http\Controllers\TelegramController;
use App\Http\Controllers\ServiceController;
use App\Http\Controllers\PlacesController;
use App\Http\Controllers\ServiceTypeController;
use App\Http\Controllers\TrainingController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
@ -53,6 +54,11 @@ Route::middleware('auth:sanctum')->group( function () {
Route::get('/places/search', [PlacesController::class, 'search']);
Route::get('/places/{id}', [PlacesController::class, 'show']);
Route::get('/trainings', [TrainingController::class, 'index']);
Route::post('/trainings', [TrainingController::class, 'createOrUpdate']);
Route::get('/trainings/{id}', [TrainingController::class, 'show']);
Route::delete('/trainings/{id}', [TrainingController::class, 'destroy']);
Route::get('/logs', [LogsController::class, 'index']);
Route::post('/telegram_login_token', [TelegramController::class, 'loginToken']);

View File

@ -129,14 +129,14 @@
<tr *ngFor="let row of displayedData; index as i">
<td>{{ data.length - (rowsPerPage * (currentPage-1) + i) }}</td>
<td>{{ row.name }}</td>
<td>{{ row.beginning }}</td>
<td>{{ row.end }}</td>
<td>{{ row.start | date:'dd/MM/YYYY, HH:mm' }}</td>
<td>{{ row.end | date:'dd/MM/YYYY, HH:mm' }}</td>
<td>{{ row.chief }}</td>
<td>{{ row.crew }}</td>
<td>{{ extractNamesFromObject(row.crew).join(', ') }}</td>
<td>{{ row.place }}</td>
<td>{{ row.notes }}</td>
<td><i class="fa fa-edit"></i></td>
<td><i class="fa fa-trash"></i></td>
<td (click)="editTraining(row.id)"><i class="fa fa-edit"></i></td>
<td (click)="deleteTraining(row.id)"><i class="fa fa-trash"></i></td>
</tr>
</tbody>
</table>

View File

@ -215,6 +215,38 @@ export class TableComponent implements OnInit, OnDestroy {
});
}
editTraining(id: number) {
this.router.navigate(['/trainings', id]);
}
deleteTraining(id: number) {
this.translate.get(['table.yes_remove', 'table.cancel', 'table.remove_training_confirm', 'table.remove_training_text']).subscribe((res: { [key: string]: string; }) => {
Swal.fire({
title: res['table.remove_training_confirm'],
text: res['table.remove_training_confirm_text'],
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: res['table.yes_remove'],
cancelButtonText: res['table.cancel']
}).then((result) => {
if (result.isConfirmed) {
this.api.delete(`trainings/${id}`).then((response) => {
this.translate.get('table.training_deleted_successfully').subscribe((res: string) => {
this.toastr.success(res);
});
this.loadTableData();
}).catch((e) => {
this.translate.get('table.training_deleted_error').subscribe((res: string) => {
this.toastr.error(res);
});
});
}
});
});
}
extractNamesFromObject(obj: any) {
return obj.flatMap((e: any) => e.name);
}

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { EditTrainingComponent } from './edit-training.component';
const routes: Routes = [{ path: '', component: EditTrainingComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class EditTrainingRoutingModule { }

View File

@ -0,0 +1,73 @@
<back-btn></back-btn>
<br>
<form method="post" [formGroup]="trainingForm" (ngSubmit)="formSubmit()">
<div class="container">
<div class="form-group has-validation">
<label for="date-picker-start">{{ 'start'|translate|titlecase }}</label>
<datetime-picker formControlName="start" [class.is-invalid]="!isFieldValid('start')" id="date-picker-start"></datetime-picker>
<div class="invalid-feedback" *ngIf="start.errors?.['required']" translate>
edit_training.select_start_datetime
</div>
</div>
<div class="form-group has-validation">
<label for="date-picker-end">{{ 'end'|translate|titlecase }}</label>
<datetime-picker formControlName="end" [class.is-invalid]="!isFieldValid('end')" id="date-picker-end"></datetime-picker>
<div class="invalid-feedback" *ngIf="end.errors?.['required']" translate>
edit_training.select_end_datetime
</div>
</div>
<div class="form-group has-validation">
<label for="name">{{ 'name'|translate|titlecase }}</label>
<input formControlName="name" [class.is-invalid]="!isFieldValid('name')" id="name" class="form-control"
type="text" [placeholder]="'edit_training.name_placeholder'|translate">
<div class="invalid-feedback" *ngIf="name.errors?.['required']" translate>
edit_training.insert_name
</div>
</div>
<div class="form-group has-validation" [class.is-invalid-div]="!isFieldValid('chief')">
<label>{{ 'chief'|translate|titlecase }}</label>
<br>
<ng-container *ngFor="let user of users">
<div class="form-check">
<input formControlName="chief" [class.is-invalid]="!isFieldValid('chief')"
class="form-check-input" id="chief-{{ user.id }}" type="radio" value='{{ user.id }}'>
<label class="form-check-label" for="chief-{{ user.id }}">
{{ user.name }}
</label>
</div>
</ng-container>
</div>
<div class="form-group has-validation" [class.is-invalid-div]="!isFieldValid('crew')">
<label translate>edit_training.other_crew_members</label>
<br>
<ng-container *ngFor="let user of users">
<div class="form-check">
<input class="form-check-input" id="crew-{{ user.id }}" [class.is-invalid]="!isFieldValid('crew')"
(change)="onCrewCheckboxChange($event)" [checked]="isCrewSelected(user.id)" type="checkbox" value='{{ user.id }}'>
<label class="form-check-label" for="crew-{{ user.id }}">
{{ user.name }}
</label>
</div>
</ng-container>
</div>
<div class="form-group has-validation">
<label for="place">{{ 'place'|translate|titlecase }}</label>
<input formControlName="place" [class.is-invalid]="!isFieldValid('place')" id="place" class="form-control"
type="text">
<div class="invalid-feedback" *ngIf="place.errors?.['required']" translate>
edit_training.insert_place
</div>
</div>
<div class="form-group">
<label for="notes">{{ 'notes'|translate|titlecase }}</label><br>
<textarea formControlName="notes" class="form-control" id="notes"></textarea>
</div>
<br>
<br>
<button id="submit_button" type="submit" class="btn btn-primary" [disabled]="submittingForm">{{ 'submit'|translate|titlecase }}</button>
<button class="btn" type="button" (click)="formReset()" [disabled]="submittingForm">{{ 'reset'|translate|titlecase }}</button>
<div class="d-flex justify-content-center mt-2 pt-2 mb-3" *ngIf="submittingForm">
<div class="spinner spinner-border"></div>
</div>
</div>
</form>

View File

@ -0,0 +1,9 @@
.form-group {
margin-bottom: 1em;
}
.form-check-input[type="checkbox"] {
margin-top: 0.5em;
}
.is-invalid-div {
border: 1px solid #dc3545;
}

View File

@ -0,0 +1,152 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, Validators } from '@angular/forms';
import { ApiClientService } from 'src/app/_services/api-client.service';
import { ToastrService } from 'ngx-toastr';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'app-edit-training',
templateUrl: './edit-training.component.html',
styleUrls: ['./edit-training.component.scss']
})
export class EditTrainingComponent implements OnInit {
addingTraining = false;
trainingId: string | undefined;
loadedTraining = {
start: '',
end: '',
name: '',
chief: '',
crew: [],
place: '',
notes: ''
};
users: any[] = [];
trainingForm: any;
private formSubmitAttempt: boolean = false;
submittingForm = false;
get start() { return this.trainingForm.get('start'); }
get end() { return this.trainingForm.get('end'); }
get name() { return this.trainingForm.get('name'); }
get chief() { return this.trainingForm.get('chief'); }
get crew() { return this.trainingForm.get('crew'); }
get place() { return this.trainingForm.get('place'); }
ngOnInit() {
this.trainingForm = this.fb.group({
start: [this.loadedTraining.start, [Validators.required]],
end: [this.loadedTraining.end, [Validators.required]],
name: [this.loadedTraining.name, [Validators.required, Validators.minLength(3)]],
chief: [this.loadedTraining.chief, [Validators.required]],
crew: [this.loadedTraining.crew, [Validators.required]],
place: [this.loadedTraining.place, [Validators.required, Validators.minLength(3)]],
notes: [this.loadedTraining.notes]
});
}
constructor(
private route: ActivatedRoute,
private api: ApiClientService,
private toastr: ToastrService,
private fb: UntypedFormBuilder,
private translate: TranslateService
) {
this.route.paramMap.subscribe(params => {
this.trainingId = params.get('id') || undefined;
if (this.trainingId === "new") {
this.addingTraining = true;
} else {
this.api.get(`trainings/${this.trainingId}`).then((training) => {
this.loadedTraining = training;
this.chief.setValue(training.chief_id);
console.log(training);
let patch = Object.assign({}, training);
patch.start = new Date(patch.start);
patch.end = new Date(patch.end);
patch.chief = patch.chief_id;
patch.crew = patch.crew.map((e: any) => e.pivot.user_id+"");
this.trainingForm.patchValue(patch);
}).catch((err) => {
this.toastr.error("Errore nel caricare l'esercitazione. Ricarica la pagina e riprova.");
});
}
console.log(this.trainingId);
});
this.api.get("list").then((users) => {
this.users = users;
console.log(this.users);
}).catch((err) => {
this.toastr.error("Errore nel caricare la lista degli utenti. Ricarica la pagina e riprova.");
});
}
onCrewCheckboxChange(event: any) {
if (event.target.checked) {
this.crew.setValue([...this.crew.value, event.target.value]);
} else {
this.crew.setValue(this.crew.value.filter((x: number) => x !== event.target.value));
}
}
isCrewSelected(id: number) {
return this.crew.value.find((x: number) => x == id);
}
//https://loiane.com/2017/08/angular-reactive-forms-trigger-validation-on-submit/
isFieldValid(field: string) {
return this.formSubmitAttempt ? this.trainingForm.get(field).valid : true;
}
formSubmit() {
console.log("form values", this.trainingForm.value);
this.formSubmitAttempt = true;
if (this.trainingForm.valid) {
this.submittingForm = true;
let values = Object.assign({}, this.trainingForm.value);
values.start = values.start.getTime();
values.end = values.end.getTime();
console.log(values);
if (this.trainingId !== "new") {
values.id = this.trainingId;
this.api.post("trainings", values).then((res) => {
console.log(res);
this.translate.get('edit_training.training_added_successfully').subscribe((res: string) => {
this.toastr.success(res);
});
this.submittingForm = false;
}).catch((err) => {
console.error(err);
this.translate.get('edit_training.training_add_failed').subscribe((res: string) => {
this.toastr.error(res);
});
this.submittingForm = false;
});
} else {
this.api.post("trainings", values).then((res) => {
console.log(res);
this.translate.get('edit_training.training_added_successfully').subscribe((res: string) => {
this.toastr.success(res);
});
this.submittingForm = false;
}).catch((err) => {
console.error(err);
this.translate.get('edit_training.training_add_failed').subscribe((res: string) => {
this.toastr.error(res);
});
this.submittingForm = false;
});
}
}
}
formReset() {
this.formSubmitAttempt = false;
this.trainingForm.reset();
}
}

View File

@ -0,0 +1,30 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { MapPickerModule } from '../../_components/map-picker/map-picker.module';
import { DatetimePickerModule } from '../../_components/datetime-picker/datetime-picker.module';
import { BackBtnModule } from '../../_components/back-btn/back-btn.module';
import { TranslationModule } from '../../translation.module';
import { EditTrainingRoutingModule } from './edit-training-routing.module';
import { EditTrainingComponent } from './edit-training.component';
@NgModule({
declarations: [
EditTrainingComponent
],
imports: [
CommonModule,
EditTrainingRoutingModule,
FormsModule,
ReactiveFormsModule,
BsDatepickerModule.forRoot(),
MapPickerModule,
DatetimePickerModule,
BackBtnModule,
TranslationModule
]
})
export class EditTrainingModule { }

View File

@ -1,8 +1,5 @@
<owner-image></owner-image>
<div *ngIf="false" class="text-center mb-4">
<button type="button" class="btn btn-primary" disabled>{{ 'add'|translate|titlecase }} {{ 'training'|translate }}</button>
</div>
<app-table *ngIf="false" [sourceType]="'trainings'" [refreshInterval]="1200000"></app-table>
<div class="alert alert-warning" role="alert">
Questa sezione di AllertaVVF è stata temporaneamente disabilitata per motivi di manutenzione. Ci scusiamo per il disagio.
<div class="text-center mb-4">
<button type="button" class="btn btn-primary" (click)="addTraining()">{{ 'add'|translate|titlecase }} {{ 'training'|translate }}</button>
</div>
<app-table [sourceType]="'trainings'" [refreshInterval]="1200000"></app-table>

View File

@ -1,4 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-trainings',
@ -7,9 +8,13 @@ import { Component, OnInit } from '@angular/core';
})
export class TrainingsComponent implements OnInit {
constructor() { }
constructor(private router: Router) { }
ngOnInit(): void {
}
addTraining() {
this.router.navigate(['trainings', 'new']);
}
}

View File

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

View File

@ -56,7 +56,16 @@
"type_already_exists": "Type already exists",
"type_added_successfully": "Type added successfully",
"service_added_successfully": "Service added successfully",
"service_add_error": "Service could not be added. Please try again"
"service_add_failed": "Service could not be added. Please try again"
},
"edit_training": {
"select_start_datetime": "Select start date and time for the training",
"select_end_datetime": "Select end date and time for the training",
"insert_name": "Insert training name",
"name_placeholder": "Training name",
"other_crew_members": "Other crew members",
"training_added_successfully": "Training added successfully",
"training_add_failed": "Training could not be added. Please try again"
},
"update_availability_schedule": "Update availability schedule",
"save_changes": "Save changes",

View File

@ -56,7 +56,16 @@
"type_already_exists": "La tipologia è già presente",
"type_added_successfully": "Tipologia aggiunta con successo",
"service_added_successfully": "Intervento aggiunto con successo",
"service_add_error": "Errore durante l'aggiunta dell'intervento. Riprovare più tardi"
"service_add_failed": "Errore durante l'aggiunta dell'intervento. Riprovare più tardi"
},
"edit_training": {
"select_start_datetime": "Seleziona data e ora di inizio dell'esercitazione",
"select_end_datetime": "Seleziona data e ora di fine dell'esercitazione",
"insert_name": "Inserisci il nome dell'esercitazione",
"name_placeholder": "Esercitazione di gennaio",
"other_crew_members": "Altri membri della squadra",
"training_added_successfully": "Esercitazione aggiunta con successo",
"training_add_failed": "Errore durante l'aggiunta dell'esercitazione. Riprovare più tardi"
},
"update_availability_schedule": "Aggiorna programmazione disponibilità",
"save_changes": "Salva modifiche",