From 4efec5642e4b4b0f2e35c24f4c21f72e980a9ca7 Mon Sep 17 00:00:00 2001 From: Tomasi - Developing Date: Tue, 21 Jan 2025 09:58:26 +0100 Subject: [PATCH] implement players import from excel file --- Api/Controllers/v1/PlayersController.cs | 38 +++++- Application/Application.csproj | 2 + Application/ApplicationDependencyInjection.cs | 1 + .../ExcelImport/ExcelImportRequest.cs | 10 ++ .../ExcelImport/ExcelImportResponse.cs | 8 ++ Application/Interfaces/IExcelService.cs | 9 ++ Application/Services/ExcelService.cs | 124 ++++++++++++++++++ Ui/src/app/app.module.ts | 4 +- .../player-excel-import-modal.component.css | 33 +++++ .../player-excel-import-modal.component.html | 60 +++++++++ .../player-excel-import-modal.component.ts | 62 +++++++++ .../app/models/excelImportResponse.model.ts | 4 + Ui/src/app/pages/player/player.component.html | 3 +- Ui/src/app/pages/player/player.component.ts | 16 +++ Ui/src/app/services/player.service.ts | 9 ++ 15 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 Application/DataTransferObjects/ExcelImport/ExcelImportRequest.cs create mode 100644 Application/DataTransferObjects/ExcelImport/ExcelImportResponse.cs create mode 100644 Application/Interfaces/IExcelService.cs create mode 100644 Application/Services/ExcelService.cs create mode 100644 Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.css create mode 100644 Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.html create mode 100644 Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.ts create mode 100644 Ui/src/app/models/excelImportResponse.model.ts diff --git a/Api/Controllers/v1/PlayersController.cs b/Api/Controllers/v1/PlayersController.cs index 63da4a2..79500bf 100644 --- a/Api/Controllers/v1/PlayersController.cs +++ b/Api/Controllers/v1/PlayersController.cs @@ -1,4 +1,5 @@ -using Application.DataTransferObjects.Player; +using Application.DataTransferObjects.ExcelImport; +using Application.DataTransferObjects.Player; using Application.Errors; using Application.Interfaces; using Asp.Versioning; @@ -12,7 +13,7 @@ namespace Api.Controllers.v1 [ApiController] [ApiVersion("1.0")] [Authorize] - public class PlayersController(IPlayerRepository playerRepository, IClaimTypeService claimTypeService, ILogger logger) : ControllerBase + public class PlayersController(IPlayerRepository playerRepository, IClaimTypeService claimTypeService, IExcelService excelService, ILogger logger) : ControllerBase { [HttpGet("{playerId:guid}")] public async Task> GetPlayer(Guid playerId, CancellationToken cancellationToken) @@ -159,6 +160,39 @@ namespace Api.Controllers.v1 } } + [HttpPost("ExcelImport")] + public async Task> ImportPlayersFromExcel( + [FromForm] ExcelImportRequest excelImportRequest, CancellationToken cancellationToken) + { + try + { + if (excelImportRequest.ExcelFile.Length == 0) + { + return BadRequest(new Error("", "No excel file upload")); + } + + var allowedExtensions = new[] { ".xlsx", ".xls" }; + var fileExtension = Path.GetExtension(excelImportRequest.ExcelFile.FileName); + + if (!allowedExtensions.Contains(fileExtension)) + { + return BadRequest(new Error("", "No supported excel file")); + } + + var excelImportResult = await excelService.ImportPlayersFromExcelAsync(excelImportRequest, + claimTypeService.GetFullName(User), cancellationToken); + + return excelImportResult.IsFailure + ? BadRequest(excelImportResult.Error) + : Ok(excelImportResult.Value); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + [HttpPut("{playerId:guid}")] public async Task> UpdatePlayer(Guid playerId, UpdatePlayerDto updatePlayerDto, CancellationToken cancellationToken) { diff --git a/Application/Application.csproj b/Application/Application.csproj index 70ac2aa..bed1d1e 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -17,6 +17,8 @@ + + diff --git a/Application/ApplicationDependencyInjection.cs b/Application/ApplicationDependencyInjection.cs index 6e87cd6..5730eee 100644 --- a/Application/ApplicationDependencyInjection.cs +++ b/Application/ApplicationDependencyInjection.cs @@ -32,6 +32,7 @@ public static class ApplicationDependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddTransient(); diff --git a/Application/DataTransferObjects/ExcelImport/ExcelImportRequest.cs b/Application/DataTransferObjects/ExcelImport/ExcelImportRequest.cs new file mode 100644 index 0000000..d630586 --- /dev/null +++ b/Application/DataTransferObjects/ExcelImport/ExcelImportRequest.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; + +namespace Application.DataTransferObjects.ExcelImport; + +public class ExcelImportRequest +{ + public required IFormFile ExcelFile { get; set; } + + public Guid AllianceId { get; set; } +} \ No newline at end of file diff --git a/Application/DataTransferObjects/ExcelImport/ExcelImportResponse.cs b/Application/DataTransferObjects/ExcelImport/ExcelImportResponse.cs new file mode 100644 index 0000000..3fc7c6e --- /dev/null +++ b/Application/DataTransferObjects/ExcelImport/ExcelImportResponse.cs @@ -0,0 +1,8 @@ +namespace Application.DataTransferObjects.ExcelImport; + +public class ExcelImportResponse +{ + public int AddSum { get; set; } + + public int SkipSum { get; set; } +} \ No newline at end of file diff --git a/Application/Interfaces/IExcelService.cs b/Application/Interfaces/IExcelService.cs new file mode 100644 index 0000000..1b86de0 --- /dev/null +++ b/Application/Interfaces/IExcelService.cs @@ -0,0 +1,9 @@ +using Application.Classes; +using Application.DataTransferObjects.ExcelImport; + +namespace Application.Interfaces; + +public interface IExcelService +{ + Task> ImportPlayersFromExcelAsync(ExcelImportRequest excelImportRequest, string createdBy, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Application/Services/ExcelService.cs b/Application/Services/ExcelService.cs new file mode 100644 index 0000000..486aa25 --- /dev/null +++ b/Application/Services/ExcelService.cs @@ -0,0 +1,124 @@ +using Application.Classes; +using Application.Interfaces; +using ExcelDataReader; +using System.Data; +using Application.DataTransferObjects.ExcelImport; +using Database; +using Database.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Application.Services; + +public class ExcelService(ApplicationContext context, ILogger logger) : IExcelService +{ + public async Task> ImportPlayersFromExcelAsync(ExcelImportRequest excelImportRequest, string createdBy, CancellationToken cancellationToken) + { + try + { + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + + await using var stream = excelImportRequest.ExcelFile.OpenReadStream(); + using var reader = ExcelReaderFactory.CreateReader(stream); + + var result = reader.AsDataSet(); + + var dataTable = result.Tables[0]; + + if (dataTable.Rows.Count == 0) + { + return Result.Failure(new Error("","Excel has no data")); + } + + var columnMapping = GetColumnMappingFromHeader(dataTable.Rows[0]); + + var requiredHeader = new[] { "Name", "Rank", "Level" }; + + foreach (var header in requiredHeader) + { + if (!columnMapping.ContainsKey(header)) + { + return Result.Failure(new Error("", $"Required header “{header}” is missing in the file.")); + } + } + + var validRanks = await context.Ranks.AsNoTracking().ToListAsync(cancellationToken); + var alliancePlayers = await context.Players.Where(p => p.AllianceId == excelImportRequest.AllianceId).Select(p => p.PlayerName.Trim().ToLower()) + .ToListAsync(cancellationToken); + + var players = new List(); + + var addCounter = 0; + var skipCounter = 0; + + for (var row = 1; row < dataTable.Rows.Count; row++) + { + var dataRow = dataTable.Rows[row]; + + var name = dataRow[columnMapping["Name"]].ToString()?.Trim(); + var level = int.TryParse(dataRow[columnMapping["Level"]].ToString(), out var parsedLevel) ? parsedLevel : 0; + var rankName = dataRow[columnMapping["Rank"]].ToString()?.Trim() ?? "R1"; + + var rank = validRanks.FirstOrDefault(r => string.Equals(r.Name, rankName, StringComparison.CurrentCultureIgnoreCase)); + + if (rank is null) + { + return Result.Failure(new Error("", $"Invalid rank “{rankName}” in row {row + 1}.")); + } + + if (string.IsNullOrEmpty(name)) + { + return Result.Failure(new Error("", $"Player name is row {row + 1} is empty")); + } + + if (alliancePlayers.Contains(name.ToLower().Trim())) + { + skipCounter++; + continue; + } + + players.Add(new Player() + { + CreatedBy = createdBy, + PlayerName = name, + Level = level, + Id = Guid.CreateVersion7(), + RankId = rank.Id, + CreatedOn = DateTime.Now, + AllianceId = excelImportRequest.AllianceId, + IsDismissed = false + }); + addCounter++; + } + + await context.Players.AddRangeAsync(players, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + return Result.Success(new ExcelImportResponse() + { + AddSum = addCounter, + SkipSum = skipCounter + }); + } + catch (Exception e) + { + logger.LogError(e, e.Message); + return Result.Failure(new Error("", "")); + } + } + + private static Dictionary GetColumnMappingFromHeader(DataRow headerRow) + { + var columnMapping = new Dictionary(); + + for (var col = 0; col < headerRow.Table.Columns.Count; col++) + { + var header = headerRow[col].ToString()?.Trim(); + if (!string.IsNullOrEmpty(header)) + { + columnMapping.TryAdd(header, col); + } + } + + return columnMapping; + } +} \ No newline at end of file diff --git a/Ui/src/app/app.module.ts b/Ui/src/app/app.module.ts index f8f3b7a..297ca61 100644 --- a/Ui/src/app/app.module.ts +++ b/Ui/src/app/app.module.ts @@ -54,6 +54,7 @@ import { CustomEventParticipantsModelComponent } from './modals/custom-event-par import { CustomEventDetailComponent } from './pages/custom-event/custom-event-detail/custom-event-detail.component'; import { DismissPlayerComponent } from './pages/dismiss-player/dismiss-player.component'; import { PlayerDismissInformationModalComponent } from './modals/player-dismiss-information-modal/player-dismiss-information-modal.component'; +import { PlayerExcelImportModalComponent } from './modals/player-excel-import-modal/player-excel-import-modal.component'; @NgModule({ declarations: [ @@ -98,7 +99,8 @@ import { PlayerDismissInformationModalComponent } from './modals/player-dismiss- CustomEventParticipantsModelComponent, CustomEventDetailComponent, DismissPlayerComponent, - PlayerDismissInformationModalComponent + PlayerDismissInformationModalComponent, + PlayerExcelImportModalComponent ], imports: [ BrowserModule, diff --git a/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.css b/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.css new file mode 100644 index 0000000..1dccdc5 --- /dev/null +++ b/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.css @@ -0,0 +1,33 @@ +/* app.component.css */ +.upload-area { + background-color: #f8f9fa; + border-radius: 10px; + padding: 30px; + transition: background-color 0.3s ease; +} + +.upload-area:hover { + background-color: #e9ecef; +} + +.upload-area i { + font-size: 4rem; +} + +.upload-area p { + font-size: 1.2rem; + margin: 10px 0; +} + +.alert-info { + font-size: 1rem; + line-height: 1.5; +} + +button { + font-size: 1rem; +} + +.text-success { + color: #198754 !important; +} diff --git a/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.html b/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.html new file mode 100644 index 0000000..cd5dd3f --- /dev/null +++ b/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.html @@ -0,0 +1,60 @@ + + + + + + + + + diff --git a/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.ts b/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.ts new file mode 100644 index 0000000..bd814b8 --- /dev/null +++ b/Ui/src/app/modals/player-excel-import-modal/player-excel-import-modal.component.ts @@ -0,0 +1,62 @@ +import {Component, inject} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {ToastrService} from "ngx-toastr"; +import Swal from "sweetalert2"; +import {JwtTokenService} from "../../services/jwt-token.service"; +import {PlayerService} from "../../services/player.service"; + +@Component({ + selector: 'app-player-excel-import-modal', + templateUrl: './player-excel-import-modal.component.html', + styleUrl: './player-excel-import-modal.component.css' +}) +export class PlayerExcelImportModalComponent { + + private readonly _toastr: ToastrService = inject(ToastrService); + private readonly _tokenService: JwtTokenService = inject(JwtTokenService); + private readonly _playerService: PlayerService = inject(PlayerService); + + public activeModal: NgbActiveModal = inject(NgbActiveModal); + selectedFile: File | null = null; + + onFileUpload() { + if (!this.selectedFile) { + Swal.fire('No file uploaded', 'Please select a file first', 'error').then(); + return; + } + this._playerService.uploadPlayerFromExcel(this._tokenService.getAllianceId()!, this.selectedFile).subscribe({ + next: ((response) => { + if (response) { + Swal.fire('Uploaded successfully', `${response.addSum} player added, ${response.skipSum} player skipped`, 'success').then(() => + this.activeModal.close(response.addSum > 0),); + } + }), + error: (error) => { + console.log(error); + if (error.error.name) { + Swal.fire('Error', error.error.name, 'error').then(() => {this.selectedFile = null;}); + } else { + this._toastr.error('Could not import players from Excel', 'error'); + } + } + }); + } + + onFileSelected(event: any): void { + const file = event.target.files[0]; + if ( + file && + (file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + file.type === 'application/vnd.ms-excel') + ) { + this.selectedFile = file; + } else { + Swal.fire('Incorrect file type', 'Please upload only .xlsx or .xls files.', 'warning').then(); + this.selectedFile = null; + } + } + + removeFile() { + this.selectedFile = null; + } +} diff --git a/Ui/src/app/models/excelImportResponse.model.ts b/Ui/src/app/models/excelImportResponse.model.ts new file mode 100644 index 0000000..7ce2459 --- /dev/null +++ b/Ui/src/app/models/excelImportResponse.model.ts @@ -0,0 +1,4 @@ +export interface ExcelImportResponseModel { + addSum: number; + skipSum: number; +} diff --git a/Ui/src/app/pages/player/player.component.html b/Ui/src/app/pages/player/player.component.html index 4abde2b..f267548 100644 --- a/Ui/src/app/pages/player/player.component.html +++ b/Ui/src/app/pages/player/player.component.html @@ -1,7 +1,8 @@

Alliance Players

- + +
Members: {{players.length}}/100
diff --git a/Ui/src/app/pages/player/player.component.ts b/Ui/src/app/pages/player/player.component.ts index dd2c0fc..d0bbfb5 100644 --- a/Ui/src/app/pages/player/player.component.ts +++ b/Ui/src/app/pages/player/player.component.ts @@ -11,6 +11,9 @@ import {PlayerEditModalComponent} from "../../modals/player-edit-modal/player-ed import Swal from 'sweetalert2' import {Router} from "@angular/router"; import {JwtTokenService} from "../../services/jwt-token.service"; +import { + PlayerExcelImportModalComponent +} from "../../modals/player-excel-import-modal/player-excel-import-modal.component"; @@ -205,4 +208,17 @@ export class PlayerComponent implements OnInit { } this.filteredPlayers = [...this.activePlayers]; } + + onAddImportFromExcel() { + const modalRef = this._modalService.open(PlayerExcelImportModalComponent, + {animation: true, backdrop: 'static', centered: true, size: 'lg'}); + modalRef.closed.subscribe({ + next: ((response: boolean) => { + if (response) { + this.filter.patchValue(''); + this.getPlayers(this.allianceId!); + } + }) + }) + } } diff --git a/Ui/src/app/services/player.service.ts b/Ui/src/app/services/player.service.ts index d4a3c09..42218f5 100644 --- a/Ui/src/app/services/player.service.ts +++ b/Ui/src/app/services/player.service.ts @@ -8,6 +8,7 @@ import { PlayerModel, UpdatePlayerModel } from "../models/player.model"; +import {ExcelImportResponseModel} from "../models/excelImportResponse.model"; @Injectable({ providedIn: 'root' @@ -56,6 +57,14 @@ export class PlayerService { return this._httpClient.post(this._serviceUrl, player); } + public uploadPlayerFromExcel(allianceId: string, excelFile: File): Observable { + const formData = new FormData(); + formData.append('excelFile', excelFile); + formData.append('allianceId', allianceId); + + return this._httpClient.post(this._serviceUrl + 'ExcelImport', formData); + } + public deletePlayer(playerId: string): Observable { return this._httpClient.delete(this._serviceUrl + playerId); }