From 1f61d2e96a1c6887825c59f85df41ece90bdcacd Mon Sep 17 00:00:00 2001 From: Matteo Gheza Date: Thu, 7 Sep 2023 14:01:54 +0200 Subject: [PATCH] Add support for trainings and small fixes --- .../Http/Controllers/ServiceController.php | 2 +- .../Http/Controllers/TrainingController.php | 104 ++++++++++++ backend/app/Models/Service.php | 8 - backend/app/Models/Training.php | 65 ++++++++ ...023_08_30_150345_create_services_table.php | 1 - ...23_09_04_163705_create_trainings_table.php | 48 ++++++ backend/routes/api.php | 6 + .../_components/table/table.component.html | 10 +- .../app/_components/table/table.component.ts | 32 ++++ .../edit-training-routing.module.ts | 11 ++ .../edit-training.component.html | 73 +++++++++ .../edit-training.component.scss | 9 ++ .../edit-training/edit-training.component.ts | 152 ++++++++++++++++++ .../edit-training/edit-training.module.ts | 30 ++++ .../trainings/trainings.component.html | 9 +- .../_routes/trainings/trainings.component.ts | 7 +- frontend/src/app/app-routing.module.ts | 5 + frontend/src/assets/i18n/en.json | 11 +- frontend/src/assets/i18n/it.json | 11 +- 19 files changed, 570 insertions(+), 24 deletions(-) create mode 100644 backend/app/Http/Controllers/TrainingController.php create mode 100644 backend/app/Models/Training.php create mode 100644 backend/database/migrations/2023_09_04_163705_create_trainings_table.php create mode 100644 frontend/src/app/_routes/edit-training/edit-training-routing.module.ts create mode 100644 frontend/src/app/_routes/edit-training/edit-training.component.html create mode 100644 frontend/src/app/_routes/edit-training/edit-training.component.scss create mode 100644 frontend/src/app/_routes/edit-training/edit-training.component.ts create mode 100644 frontend/src/app/_routes/edit-training/edit-training.module.ts diff --git a/backend/app/Http/Controllers/ServiceController.php b/backend/app/Http/Controllers/ServiceController.php index f074106..da1aeac 100644 --- a/backend/app/Http/Controllers/ServiceController.php +++ b/backend/app/Http/Controllers/ServiceController.php @@ -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()); diff --git a/backend/app/Http/Controllers/TrainingController.php b/backend/app/Http/Controllers/TrainingController.php new file mode 100644 index 0000000..fa68f7c --- /dev/null +++ b/backend/app/Http/Controllers/TrainingController.php @@ -0,0 +1,104 @@ +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"); + } +} diff --git a/backend/app/Models/Service.php b/backend/app/Models/Service.php index d09a97a..3f9d624 100644 --- a/backend/app/Models/Service.php +++ b/backend/app/Models/Service.php @@ -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); - } } diff --git a/backend/app/Models/Training.php b/backend/app/Models/Training.php new file mode 100644 index 0000000..cf4208b --- /dev/null +++ b/backend/app/Models/Training.php @@ -0,0 +1,65 @@ + + */ + protected $fillable = [ + 'name', + 'place', + 'notes' + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + 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); + } +} diff --git a/backend/database/migrations/2023_08_30_150345_create_services_table.php b/backend/database/migrations/2023_08_30_150345_create_services_table.php index d31bd85..fbef72d 100644 --- a/backend/database/migrations/2023_08_30_150345_create_services_table.php +++ b/backend/database/migrations/2023_08_30_150345_create_services_table.php @@ -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(); diff --git a/backend/database/migrations/2023_09_04_163705_create_trainings_table.php b/backend/database/migrations/2023_09_04_163705_create_trainings_table.php new file mode 100644 index 0000000..fe85746 --- /dev/null +++ b/backend/database/migrations/2023_09_04_163705_create_trainings_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index c420c3f..601b084 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -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']); diff --git a/frontend/src/app/_components/table/table.component.html b/frontend/src/app/_components/table/table.component.html index 652c2c2..f2bae25 100644 --- a/frontend/src/app/_components/table/table.component.html +++ b/frontend/src/app/_components/table/table.component.html @@ -129,14 +129,14 @@ {{ data.length - (rowsPerPage * (currentPage-1) + i) }} {{ row.name }} - {{ row.beginning }} - {{ row.end }} + {{ row.start | date:'dd/MM/YYYY, HH:mm' }} + {{ row.end | date:'dd/MM/YYYY, HH:mm' }} {{ row.chief }} - {{ row.crew }} + {{ extractNamesFromObject(row.crew).join(', ') }} {{ row.place }} {{ row.notes }} - - + + diff --git a/frontend/src/app/_components/table/table.component.ts b/frontend/src/app/_components/table/table.component.ts index 3375d86..c46a4ef 100644 --- a/frontend/src/app/_components/table/table.component.ts +++ b/frontend/src/app/_components/table/table.component.ts @@ -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); } diff --git a/frontend/src/app/_routes/edit-training/edit-training-routing.module.ts b/frontend/src/app/_routes/edit-training/edit-training-routing.module.ts new file mode 100644 index 0000000..4e28cb1 --- /dev/null +++ b/frontend/src/app/_routes/edit-training/edit-training-routing.module.ts @@ -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 { } diff --git a/frontend/src/app/_routes/edit-training/edit-training.component.html b/frontend/src/app/_routes/edit-training/edit-training.component.html new file mode 100644 index 0000000..ca9b0a6 --- /dev/null +++ b/frontend/src/app/_routes/edit-training/edit-training.component.html @@ -0,0 +1,73 @@ + +
+
+
+
+ + +
+ edit_training.select_start_datetime +
+
+
+ + +
+ edit_training.select_end_datetime +
+
+
+ + +
+ edit_training.insert_name +
+
+
+ +
+ +
+ + +
+
+
+
+ +
+ +
+ + +
+
+
+
+ + +
+ edit_training.insert_place +
+
+
+
+ +
+
+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/_routes/edit-training/edit-training.component.scss b/frontend/src/app/_routes/edit-training/edit-training.component.scss new file mode 100644 index 0000000..a646ad2 --- /dev/null +++ b/frontend/src/app/_routes/edit-training/edit-training.component.scss @@ -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; +} \ No newline at end of file diff --git a/frontend/src/app/_routes/edit-training/edit-training.component.ts b/frontend/src/app/_routes/edit-training/edit-training.component.ts new file mode 100644 index 0000000..7d132fb --- /dev/null +++ b/frontend/src/app/_routes/edit-training/edit-training.component.ts @@ -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(); + } +} diff --git a/frontend/src/app/_routes/edit-training/edit-training.module.ts b/frontend/src/app/_routes/edit-training/edit-training.module.ts new file mode 100644 index 0000000..ecc9273 --- /dev/null +++ b/frontend/src/app/_routes/edit-training/edit-training.module.ts @@ -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 { } diff --git a/frontend/src/app/_routes/trainings/trainings.component.html b/frontend/src/app/_routes/trainings/trainings.component.html index 4de40b5..e56133d 100644 --- a/frontend/src/app/_routes/trainings/trainings.component.html +++ b/frontend/src/app/_routes/trainings/trainings.component.html @@ -1,8 +1,5 @@ -
- -
- -