implement players import from excel file

This commit is contained in:
Tomasi - Developing 2025-01-21 09:58:26 +01:00
parent 9931a9a3c2
commit 4efec5642e
15 changed files with 379 additions and 4 deletions

View File

@ -1,4 +1,5 @@
using Application.DataTransferObjects.Player; using Application.DataTransferObjects.ExcelImport;
using Application.DataTransferObjects.Player;
using Application.Errors; using Application.Errors;
using Application.Interfaces; using Application.Interfaces;
using Asp.Versioning; using Asp.Versioning;
@ -12,7 +13,7 @@ namespace Api.Controllers.v1
[ApiController] [ApiController]
[ApiVersion("1.0")] [ApiVersion("1.0")]
[Authorize] [Authorize]
public class PlayersController(IPlayerRepository playerRepository, IClaimTypeService claimTypeService, ILogger<PlayersController> logger) : ControllerBase public class PlayersController(IPlayerRepository playerRepository, IClaimTypeService claimTypeService, IExcelService excelService, ILogger<PlayersController> logger) : ControllerBase
{ {
[HttpGet("{playerId:guid}")] [HttpGet("{playerId:guid}")]
public async Task<ActionResult<PlayerDto>> GetPlayer(Guid playerId, CancellationToken cancellationToken) public async Task<ActionResult<PlayerDto>> GetPlayer(Guid playerId, CancellationToken cancellationToken)
@ -159,6 +160,39 @@ namespace Api.Controllers.v1
} }
} }
[HttpPost("ExcelImport")]
public async Task<ActionResult<ExcelImportResponse>> 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}")] [HttpPut("{playerId:guid}")]
public async Task<ActionResult<PlayerDto>> UpdatePlayer(Guid playerId, UpdatePlayerDto updatePlayerDto, CancellationToken cancellationToken) public async Task<ActionResult<PlayerDto>> UpdatePlayer(Guid playerId, UpdatePlayerDto updatePlayerDto, CancellationToken cancellationToken)
{ {

View File

@ -17,6 +17,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper" Version="13.0.1" /> <PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="ExcelDataReader" Version="3.7.0" />
<PackageReference Include="ExcelDataReader.DataSet" Version="3.7.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
</ItemGroup> </ItemGroup>

View File

@ -32,6 +32,7 @@ public static class ApplicationDependencyInjection
services.AddScoped<IZombieSiegeRepository, ZombieSiegeRepository>(); services.AddScoped<IZombieSiegeRepository, ZombieSiegeRepository>();
services.AddScoped<IZombieSiegeParticipantRepository, ZombieSiegeParticipantRepository>(); services.AddScoped<IZombieSiegeParticipantRepository, ZombieSiegeParticipantRepository>();
services.AddScoped<IVsDuelLeagueRepository, VsDuelLeagueRepository>(); services.AddScoped<IVsDuelLeagueRepository, VsDuelLeagueRepository>();
services.AddScoped<IExcelService, ExcelService>();
services.AddTransient<IJwtService, JwtService>(); services.AddTransient<IJwtService, JwtService>();

View File

@ -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; }
}

View File

@ -0,0 +1,8 @@
namespace Application.DataTransferObjects.ExcelImport;
public class ExcelImportResponse
{
public int AddSum { get; set; }
public int SkipSum { get; set; }
}

View File

@ -0,0 +1,9 @@
using Application.Classes;
using Application.DataTransferObjects.ExcelImport;
namespace Application.Interfaces;
public interface IExcelService
{
Task<Result<ExcelImportResponse>> ImportPlayersFromExcelAsync(ExcelImportRequest excelImportRequest, string createdBy, CancellationToken cancellationToken);
}

View File

@ -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<ExcelService> logger) : IExcelService
{
public async Task<Result<ExcelImportResponse>> 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<ExcelImportResponse>(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<ExcelImportResponse>(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<Player>();
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<ExcelImportResponse>(new Error("", $"Invalid rank “{rankName}” in row {row + 1}."));
}
if (string.IsNullOrEmpty(name))
{
return Result.Failure<ExcelImportResponse>(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<ExcelImportResponse>(new Error("", ""));
}
}
private static Dictionary<string, int> GetColumnMappingFromHeader(DataRow headerRow)
{
var columnMapping = new Dictionary<string, int>();
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;
}
}

View File

@ -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 { CustomEventDetailComponent } from './pages/custom-event/custom-event-detail/custom-event-detail.component';
import { DismissPlayerComponent } from './pages/dismiss-player/dismiss-player.component'; import { DismissPlayerComponent } from './pages/dismiss-player/dismiss-player.component';
import { PlayerDismissInformationModalComponent } from './modals/player-dismiss-information-modal/player-dismiss-information-modal.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({ @NgModule({
declarations: [ declarations: [
@ -98,7 +99,8 @@ import { PlayerDismissInformationModalComponent } from './modals/player-dismiss-
CustomEventParticipantsModelComponent, CustomEventParticipantsModelComponent,
CustomEventDetailComponent, CustomEventDetailComponent,
DismissPlayerComponent, DismissPlayerComponent,
PlayerDismissInformationModalComponent PlayerDismissInformationModalComponent,
PlayerExcelImportModalComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -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;
}

View File

@ -0,0 +1,60 @@
<div class="modal-header" xmlns="http://www.w3.org/1999/html">
<h4 class="modal-title">Import players from Excel</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="activeModal.dismiss()"></button>
</div>
<div class="modal-body">
<!-- Description -->
<div class="alert alert-secondary" role="alert">
<p>
This feature allows you to upload an Excel file to add players. Please follow the guidelines below:
</p>
<ul>
<li>The Excel file must have <strong>headers in the first row</strong> (the first row of your Excel sheet).</li>
<li>Players with already existing names will <strong>not be imported</strong>.</li>
<li>The file must include the following headers (order does not matter):
<ul>
<li><strong>Name</strong>: The player's name</li>
<li><strong>Rank</strong>: The player's rank (R1 - R5)</li>
<li><strong>Level</strong>: The player's headquarters level</li>
</ul>
</li>
<li>Additional headers in the file will be <strong>ignored</strong>.</li>
</ul>
</div>
<!-- Upload Area -->
<div class="upload-area text-center p-5 border border-primary rounded">
@if (!selectedFile) {
<div>
<i class="bi bi-file-earmark-excel-fill display-3 text-primary"></i>
<p class="mt-3">Choose an Excel file (.xlsx or .xls)</p>
<input
type="file"
class="form-control d-none"
id="fileInput"
(change)="onFileSelected($event)"
accept=".xlsx, .xls"
#fileInput
/>
<button class="btn btn-primary mt-3" (click)="fileInput.click()">Select File</button>
</div>
} @else {
<div>
<i class="bi bi-file-earmark-check-fill display-3 text-success"></i>
<p class="mt-3"><strong>{{ selectedFile.name }}</strong></p>
<button class="btn btn-danger mt-3" (click)="removeFile()">Remove File</button>
</div>
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" (click)="activeModal.dismiss()">Close</button>
<button (click)="onFileUpload()" [disabled]="!selectedFile" type="submit" class=" btn btn-success">Upload</button>
</div>

View File

@ -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;
}
}

View File

@ -0,0 +1,4 @@
export interface ExcelImportResponseModel {
addSum: number;
skipSum: number;
}

View File

@ -1,7 +1,8 @@
<div class="container mt-3 pb-5"> <div class="container mt-3 pb-5">
<h2 class="text-center">Alliance Players</h2> <h2 class="text-center">Alliance Players</h2>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button (click)="onAddNewPlayer()" class="btn btn-info mb-3">Add new Player</button> <button (click)="onAddNewPlayer()" class="btn btn-info mb-3"> <i class="bi bi-person-plus"></i> Add new Player</button>
<button (click)="onAddImportFromExcel()" class="btn btn-info mb-3 ms-3"><i class="bi bi-filetype-xlsx"></i> Import from Excel</button>
</div> </div>
<h5 class="mb-3"><span class="badge text-bg-dark">Members: {{players.length}}/100</span></h5> <h5 class="mb-3"><span class="badge text-bg-dark">Members: {{players.length}}/100</span></h5>

View File

@ -11,6 +11,9 @@ import {PlayerEditModalComponent} from "../../modals/player-edit-modal/player-ed
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import {Router} from "@angular/router"; import {Router} from "@angular/router";
import {JwtTokenService} from "../../services/jwt-token.service"; 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]; 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!);
}
})
})
}
} }

View File

@ -8,6 +8,7 @@ import {
PlayerModel, PlayerModel,
UpdatePlayerModel UpdatePlayerModel
} from "../models/player.model"; } from "../models/player.model";
import {ExcelImportResponseModel} from "../models/excelImportResponse.model";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -56,6 +57,14 @@ export class PlayerService {
return this._httpClient.post<PlayerModel>(this._serviceUrl, player); return this._httpClient.post<PlayerModel>(this._serviceUrl, player);
} }
public uploadPlayerFromExcel(allianceId: string, excelFile: File): Observable<ExcelImportResponseModel> {
const formData = new FormData();
formData.append('excelFile', excelFile);
formData.append('allianceId', allianceId);
return this._httpClient.post<ExcelImportResponseModel>(this._serviceUrl + 'ExcelImport', formData);
}
public deletePlayer(playerId: string): Observable<boolean> { public deletePlayer(playerId: string): Observable<boolean> {
return this._httpClient.delete<boolean>(this._serviceUrl + playerId); return this._httpClient.delete<boolean>(this._serviceUrl + playerId);
} }