many bug fixes and features added

This commit is contained in:
2026-06-27 12:15:37 -04:00
parent f60174625e
commit 78dbbc2051
51 changed files with 1459 additions and 124 deletions

View File

@@ -1,3 +1,4 @@
using SportsDivision.Domain.Enums;
namespace SportsDivision.Application.DTOs;
public class TournamentEventLevelDto { public int TournamentEventLevelId { get; set; } public int TournamentId { get; set; } public int EventId { get; set; } public string EventName { get; set; } = string.Empty; public int EventLevelId { get; set; } public string EventLevelName { get; set; } = string.Empty; public bool AgeRestrictionWaived { get; set; } public int RegistrationCount { get; set; } }
public class TournamentEventLevelDto { public int TournamentEventLevelId { get; set; } public int TournamentId { get; set; } public string TournamentName { get; set; } = string.Empty; public int EventId { get; set; } public string EventName { get; set; } = string.Empty; public EventCategory Category { get; set; } public int EventLevelId { get; set; } public string EventLevelName { get; set; } = string.Empty; public bool AgeRestrictionWaived { get; set; } public int RegistrationCount { get; set; } }
public class TournamentEventLevelCreateDto { public int TournamentId { get; set; } public int EventId { get; set; } public int EventLevelId { get; set; } public bool AgeRestrictionWaived { get; set; } }

View File

@@ -1,3 +1,3 @@
using SportsDivision.Application.DTOs;
namespace SportsDivision.Application.Interfaces;
public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); }
public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); }

View File

@@ -1,4 +1,4 @@
using SportsDivision.Application.DTOs;
using SportsDivision.Domain.Enums;
namespace SportsDivision.Application.Interfaces;
public interface ITournamentService { Task<IEnumerable<TournamentDto>> GetAllAsync(bool includeArchived = false); Task<TournamentDto?> GetByIdAsync(int id); Task<TournamentDto> CreateAsync(TournamentCreateDto dto); Task UpdateAsync(TournamentUpdateDto dto); Task UpdateStatusAsync(int id, TournamentStatus status); Task ArchiveAsync(int id); Task UnarchiveAsync(int id); Task DeleteAsync(int id); Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsAsync(int tournamentId); Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto); Task RemoveEventLevelAsync(int tournamentEventLevelId); Task ToggleAgeWaiverAsync(int tournamentEventLevelId); }
public interface ITournamentService { Task<IEnumerable<TournamentDto>> GetAllAsync(bool includeArchived = false); Task<TournamentDto?> GetByIdAsync(int id); Task<TournamentDto> CreateAsync(TournamentCreateDto dto); Task UpdateAsync(TournamentUpdateDto dto); Task UpdateStatusAsync(int id, TournamentStatus status); Task ArchiveAsync(int id); Task UnarchiveAsync(int id); Task DeleteAsync(int id); Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsAsync(int tournamentId); Task<TournamentEventLevelDto?> GetEventLevelByIdAsync(int tournamentEventLevelId); Task<IEnumerable<EventLevelDto>> GetAllEventLevelsAsync(); Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsByCategoryAsync(EventCategory category); Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto); Task RemoveEventLevelAsync(int tournamentEventLevelId); Task ToggleAgeWaiverAsync(int tournamentEventLevelId); }

View File

@@ -37,7 +37,9 @@ public class MappingProfile : Profile
CreateMap<TournamentUpdateDto, Tournament>();
CreateMap<TournamentEventLevel, TournamentEventLevelDto>()
.ForMember(d => d.TournamentName, o => o.MapFrom(s => s.Tournament != null ? s.Tournament.Name : string.Empty))
.ForMember(d => d.EventName, o => o.MapFrom(s => s.Event != null ? s.Event.Name : string.Empty))
.ForMember(d => d.Category, o => o.MapFrom(s => s.Event != null ? s.Event.Category : default))
.ForMember(d => d.EventLevelName, o => o.MapFrom(s => s.EventLevel != null ? s.EventLevel.Name : string.Empty))
.ForMember(d => d.RegistrationCount, o => o.MapFrom(s => s.Registrations != null ? s.Registrations.Count : 0));
CreateMap<TournamentEventLevelCreateDto, TournamentEventLevel>();

View File

@@ -26,7 +26,7 @@ public class EventService : IEventService
public async Task<EventDto?> GetByIdAsync(int id)
{
var evt = await _uow.Events.GetByIdAsync(id);
var evt = await _uow.Events.GetWithEventLevelsAsync(id);
return evt == null ? null : _mapper.Map<EventDto>(evt);
}

View File

@@ -146,28 +146,32 @@ public class HighJumpService : IHighJumpService
.ThenBy(r => r.TotalFails)
.ToList();
// Record scores
// Record scores: cleared height, placement, placement points, and World-Athletics points.
var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync())
.ToDictionary(p => p.Placement, p => p.Points);
var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId);
for (int i = 0; i < sorted.Count; i++)
{
int place = i + 1;
decimal cleared = sorted[i].HighestCleared;
int calc = (constant != null && cleared > constant.B)
? (int)Math.Floor((double)constant.A * Math.Pow((double)(cleared - constant.B), (double)constant.C))
: 0;
var score = await _uow.Scores.GetByRegistrationAsync(sorted[i].RegId);
if (score == null)
{
score = new Score
{
EventRegistrationId = sorted[i].RegId,
RawPerformance = sorted[i].HighestCleared,
RecordedBy = recordedBy,
RecordedAt = DateTime.UtcNow
};
await _uow.Scores.AddAsync(score);
}
else
{
score.RawPerformance = sorted[i].HighestCleared;
score.RecordedBy = recordedBy;
score.RecordedAt = DateTime.UtcNow;
_uow.Scores.Update(score);
}
var isNew = score == null;
score ??= new Score { EventRegistrationId = sorted[i].RegId };
score.RawPerformance = cleared;
score.CalculatedPoints = calc;
score.Placement = place;
score.PlacementPoints = placementPoints.TryGetValue(place, out var pts) ? pts : 0;
score.RecordedBy = recordedBy;
score.RecordedAt = DateTime.UtcNow;
if (isNew) await _uow.Scores.AddAsync(score);
else _uow.Scores.Update(score);
}
await _uow.SaveChangesAsync();
}

View File

@@ -26,7 +26,7 @@ public class SchoolService : ISchoolService
public async Task<SchoolDto?> GetByIdAsync(int id)
{
var school = await _uow.Schools.GetByIdAsync(id);
var school = await _uow.Schools.GetWithStudentsAsync(id);
return school == null ? null : _mapper.Map<SchoolDto>(school);
}

View File

@@ -89,6 +89,62 @@ public class ScoringService : IScoringService
await _uow.SaveChangesAsync();
}
public async Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy)
{
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
?? throw new KeyNotFoundException("Tournament event level not found.");
if (tel.Event.Category != Domain.Enums.EventCategory.Track)
throw new InvalidOperationException("Final standings can only be calculated for track events.");
var rounds = (await _uow.Rounds.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList();
var finalRound = rounds.FirstOrDefault(r => r.RoundType == Domain.Enums.RoundType.Final)
?? rounds.OrderByDescending(r => r.RoundOrder).FirstOrDefault()
?? throw new InvalidOperationException("This event has no rounds yet — seed heats and record times first.");
// Finishers in the final = lanes with a recorded time (DNS/DNF/DQ excluded), fastest first.
var finishers = finalRound.Heats
.SelectMany(h => h.HeatLanes)
.Where(l => l.Time.HasValue && !l.IsDNS && !l.IsDNF && !l.IsDQ)
.OrderBy(l => l.Time)
.ToList();
if (finishers.Count == 0)
throw new InvalidOperationException("The final round has no recorded times yet.");
var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId);
var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync())
.ToDictionary(p => p.Placement, p => p.Points);
var finalistRegIds = finishers.Select(f => f.EventRegistrationId).ToHashSet();
// Points come from the final only: drop scores for anyone not in the final.
var existing = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList();
foreach (var sc in existing.Where(s => !finalistRegIds.Contains(s.EventRegistrationId)))
_uow.Scores.Remove(sc);
for (int i = 0; i < finishers.Count; i++)
{
var lane = finishers[i];
int place = i + 1;
int calc = constant != null
? CalculatePoints(lane.Time!.Value, constant.A, constant.B, constant.C, isTrack: true)
: 0;
var score = await _uow.Scores.GetByRegistrationAsync(lane.EventRegistrationId);
var isNew = score == null;
score ??= new Score { EventRegistrationId = lane.EventRegistrationId };
score.RawPerformance = lane.Time!.Value;
score.CalculatedPoints = calc;
score.Placement = place;
score.PlacementPoints = placementPoints.TryGetValue(place, out var pts) ? pts : 0;
score.RecordedBy = recordedBy;
score.RecordedAt = DateTime.UtcNow;
if (isNew) await _uow.Scores.AddAsync(score);
else _uow.Scores.Update(score);
}
await _uow.SaveChangesAsync();
}
public async Task CalculatePlacementsAsync(int tournamentEventLevelId)
{
var scores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId))

View File

@@ -90,6 +90,35 @@ public class TournamentService : ITournamentService
return _mapper.Map<IEnumerable<TournamentEventLevelDto>>(tels);
}
public async Task<TournamentEventLevelDto?> GetEventLevelByIdAsync(int tournamentEventLevelId)
{
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId);
return tel == null ? null : _mapper.Map<TournamentEventLevelDto>(tel);
}
public async Task<IEnumerable<EventLevelDto>> GetAllEventLevelsAsync()
{
var levels = await _uow.EventLevels.GetAllAsync();
return _mapper.Map<IEnumerable<EventLevelDto>>(levels.OrderBy(l => l.SortOrder));
}
public async Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsByCategoryAsync(EventCategory category)
{
var tournaments = await _uow.Tournaments.GetActiveAsync();
var result = new List<TournamentEventLevelDto>();
foreach (var t in tournaments)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(t.TournamentId);
foreach (var tel in tels.Where(x => x.Event != null && x.Event.Category == category))
{
var dto = _mapper.Map<TournamentEventLevelDto>(tel);
dto.TournamentName = t.Name;
result.Add(dto);
}
}
return result;
}
public async Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto)
{
var existing = await _uow.TournamentEventLevels.FindAsync(dto.TournamentId, dto.EventId, dto.EventLevelId);

View File

@@ -8,7 +8,7 @@ namespace SportsDivision.Infrastructure.Repositories;
public class EventRegistrationRepository : Repository<EventRegistration>, IEventRegistrationRepository
{
public EventRegistrationRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<EventRegistration>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Student).ThenInclude(s => s!.School).Include(r => r.Score).ToListAsync();
public async Task<IEnumerable<EventRegistration>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Student).ThenInclude(s => s!.School).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
public async Task<IEnumerable<EventRegistration>> GetByStudentAsync(int studentId) => await _dbSet.Where(r => r.StudentId == studentId).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Tournament).Include(r => r.Score).ToListAsync();
public async Task<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId);
public async Task<IEnumerable<EventRegistration>> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId) => await _dbSet.Where(r => r.Student != null && r.Student.SchoolId == schoolId && r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();

View File

@@ -17,7 +17,7 @@ public class Repository<T> : IRepository<T> where T : class
}
public async Task<T?> GetByIdAsync(int id) => await _dbSet.FindAsync(id);
public async Task<IEnumerable<T>> GetAllAsync() => await _dbSet.ToListAsync();
public virtual async Task<IEnumerable<T>> GetAllAsync() => await _dbSet.ToListAsync();
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) => await _dbSet.Where(predicate).ToListAsync();
public async Task<T> AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; }
public async Task AddRangeAsync(IEnumerable<T> entities) => await _dbSet.AddRangeAsync(entities);

View File

@@ -8,6 +8,6 @@ namespace SportsDivision.Infrastructure.Repositories;
public class RoundRepository : Repository<Round>, IRoundRepository
{
public RoundRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<Round>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Heats).OrderBy(r => r.RoundOrder).ToListAsync();
public async Task<IEnumerable<Round>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Heats).ThenInclude(h => h.HeatLanes).OrderBy(r => r.RoundOrder).ToListAsync();
public async Task<Round?> GetWithHeatsAsync(int roundId) => await _dbSet.Include(r => r.Heats).ThenInclude(h => h.HeatLanes).ThenInclude(hl => hl.EventRegistration).ThenInclude(er => er.Student).ThenInclude(s => s!.School).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).FirstOrDefaultAsync(r => r.RoundId == roundId);
}

View File

@@ -9,7 +9,8 @@ namespace SportsDivision.Infrastructure.Repositories;
public class SchoolRepository : Repository<School>, ISchoolRepository
{
public SchoolRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<School>> GetByZoneAsync(int zoneId) => await _dbSet.Where(s => s.ZoneId == zoneId).Include(s => s.Zone).OrderBy(s => s.Name).ToListAsync();
public async Task<IEnumerable<School>> GetBySchoolLevelAsync(SchoolLevel level) => await _dbSet.Where(s => s.SchoolLevel == level).Include(s => s.Zone).OrderBy(s => s.Name).ToListAsync();
public override async Task<IEnumerable<School>> GetAllAsync() => await _dbSet.Include(s => s.Zone).Include(s => s.Students).OrderBy(s => s.Name).ToListAsync();
public async Task<IEnumerable<School>> GetByZoneAsync(int zoneId) => await _dbSet.Where(s => s.ZoneId == zoneId).Include(s => s.Zone).Include(s => s.Students).OrderBy(s => s.Name).ToListAsync();
public async Task<IEnumerable<School>> GetBySchoolLevelAsync(SchoolLevel level) => await _dbSet.Where(s => s.SchoolLevel == level).Include(s => s.Zone).Include(s => s.Students).OrderBy(s => s.Name).ToListAsync();
public async Task<School?> GetWithStudentsAsync(int schoolId) => await _dbSet.Include(s => s.Students).Include(s => s.Zone).FirstOrDefaultAsync(s => s.SchoolId == schoolId);
}

View File

@@ -8,5 +8,6 @@ namespace SportsDivision.Infrastructure.Repositories;
public class ScoringConstantRepository : Repository<ScoringConstant>, IScoringConstantRepository
{
public ScoringConstantRepository(ApplicationDbContext context) : base(context) { }
public override async Task<IEnumerable<ScoringConstant>> GetAllAsync() => await _dbSet.Include(s => s.Event).OrderBy(s => s.Event!.Name).ToListAsync();
public async Task<ScoringConstant?> GetByEventAsync(int eventId) => await _dbSet.FirstOrDefaultAsync(s => s.EventId == eventId);
}

View File

@@ -8,7 +8,8 @@ namespace SportsDivision.Infrastructure.Repositories;
public class TournamentRepository : Repository<Tournament>, ITournamentRepository
{
public TournamentRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<Tournament>> GetActiveAsync() => await _dbSet.Where(t => !t.IsArchived).Include(t => t.Zone).OrderByDescending(t => t.StartDate).ToListAsync();
public override async Task<IEnumerable<Tournament>> GetAllAsync() => await _dbSet.Include(t => t.Zone).Include(t => t.TournamentEventLevels).OrderByDescending(t => t.StartDate).ToListAsync();
public async Task<IEnumerable<Tournament>> GetActiveAsync() => await _dbSet.Where(t => !t.IsArchived).Include(t => t.Zone).Include(t => t.TournamentEventLevels).OrderByDescending(t => t.StartDate).ToListAsync();
public async Task<Tournament?> GetWithEventLevelsAsync(int tournamentId) => await _dbSet.Include(t => t.TournamentEventLevels).ThenInclude(te => te.Event).Include(t => t.TournamentEventLevels).ThenInclude(te => te.EventLevel).Include(t => t.Zone).FirstOrDefaultAsync(t => t.TournamentId == tournamentId);
public async Task<Tournament?> GetFullTournamentAsync(int tournamentId) => await _dbSet.Include(t => t.Zone).Include(t => t.TournamentEventLevels).ThenInclude(te => te.Event).Include(t => t.TournamentEventLevels).ThenInclude(te => te.EventLevel).Include(t => t.TournamentEventLevels).ThenInclude(te => te.Registrations).ThenInclude(r => r.Student).ThenInclude(s => s!.School).FirstOrDefaultAsync(t => t.TournamentId == tournamentId);
}

View File

@@ -248,9 +248,9 @@ public class DatabaseSeeder
Add("400m", 1.53775m, 82m, 1.81m, "seconds");
Add("1500m", 0.03768m, 480m, 1.85m, "seconds");
Add("80mH", 5.74352m, 28.5m, 1.92m, "seconds");
Add("Long Jump", 0.14354m, 220m, 1.4m, "centimetres");
Add("Triple Jump", 0.14354m, 220m, 1.4m, "centimetres");
Add("High Jump", 0.8465m, 75m, 1.42m, "centimetres");
Add("Long Jump", 90.56762m, 2.2m, 1.4m, "metres");
Add("Triple Jump", 90.56762m, 2.2m, 1.4m, "metres");
Add("High Jump", 585.63492m, 0.75m, 1.42m, "metres");
Add("Shot Put 3kg", 51.39m, 1.5m, 1.05m, "metres"); Add("Shot Put 4kg", 51.39m, 1.5m, 1.05m, "metres");
Add("Shot Put 5kg", 51.39m, 1.5m, 1.05m, "metres"); Add("Shot Put 6kg", 51.39m, 1.5m, 1.05m, "metres");
Add("Discus 1kg", 12.91m, 4m, 1.1m, "metres"); Add("Discus 1.25kg", 12.91m, 4m, 1.1m, "metres");

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums;
using SportsDivision.Web.Helpers;
namespace SportsDivision.Web.Controllers;
@@ -16,7 +17,7 @@ public class EventController : Controller
_eventService = eventService;
}
public async Task<IActionResult> Index(EventCategory? category)
public async Task<IActionResult> Index(EventCategory? category, int page = 1, int pageSize = PaginationHelper.PageSize)
{
IEnumerable<EventDto> events;
@@ -31,7 +32,7 @@ public class EventController : Controller
ViewBag.SelectedCategory = category;
return View(events);
return View(this.Page(events, page, pageSize));
}
[HttpGet]

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums;
namespace SportsDivision.Web.Controllers;
@@ -25,17 +26,26 @@ public class FieldEventController : Controller
[HttpGet]
public async Task<IActionResult> Index(int tournamentEventLevelId)
{
if (tournamentEventLevelId <= 0)
{
ViewBag.ScoringTargets = await _tournamentService.GetEventLevelsByCategoryAsync(EventCategory.Field);
ViewBag.ScoringController = "FieldEvent";
ViewBag.ScoringTitle = "Field Event Scoring";
return View("SelectEventLevel");
}
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
return View(registrations);
}
[HttpPost]
public async Task<IActionResult> RecordScore([FromBody] ScoreCreateDto dto)
[ValidateAntiForgeryToken]
public async Task<IActionResult> RecordScore(ScoreCreateDto dto, int tournamentEventLevelId)
{
var recordedBy = User.Identity?.Name ?? "Unknown";
var score = await _scoringService.RecordScoreAsync(dto, recordedBy);
return Json(new { success = true, score });
await _scoringService.RecordScoreAsync(dto, recordedBy);
TempData["SuccessMessage"] = "Performance recorded.";
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
[HttpPost]

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums;
namespace SportsDivision.Web.Controllers;
@@ -28,6 +29,13 @@ public class HighJumpController : Controller
[HttpGet]
public async Task<IActionResult> Index(int tournamentEventLevelId)
{
if (tournamentEventLevelId <= 0)
{
ViewBag.ScoringTargets = await _tournamentService.GetEventLevelsByCategoryAsync(EventCategory.HighJump);
ViewBag.ScoringController = "HighJump";
ViewBag.ScoringTitle = "High Jump Scoring";
return View("SelectEventLevel");
}
var heights = await _highJumpService.GetHeightsAsync(tournamentEventLevelId);
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
@@ -57,12 +65,11 @@ public class HighJumpController : Controller
}
[HttpPost]
public async Task<IActionResult> RecordAttempt([FromBody] HighJumpAttemptUpdateDto dto)
[ValidateAntiForgeryToken]
public async Task<IActionResult> RecordAttempt(HighJumpAttemptUpdateDto dto, int tournamentEventLevelId)
{
await _highJumpService.RecordAttemptAsync(dto);
var isEliminated = await _highJumpService.IsEliminatedAsync(
dto.HighJumpHeightId, dto.EventRegistrationId);
return Json(new { success = true, isEliminated });
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
[HttpPost]

View File

@@ -27,6 +27,7 @@ public class ReportController : Controller
public async Task<IActionResult> Index()
{
var tournaments = await _tournamentService.GetAllAsync();
ViewBag.Tournaments = tournaments;
return View(tournaments);
}
@@ -154,4 +155,65 @@ public class ReportController : Controller
var pdf = document.GeneratePdf();
return File(pdf, "application/pdf", $"StudentPoints_{tournamentId}.pdf");
}
// ----- Excel (.xlsx) exports -----
private async Task<string> TournamentNameAsync(int tournamentId)
=> (await _tournamentService.GetByIdAsync(tournamentId))?.Name ?? "Tournament";
[HttpGet]
public async Task<IActionResult> PopularEventsExcel(int tournamentId)
{
var report = await _reportService.GetPopularEventsAsync(tournamentId);
var bytes = ReportExcelExporter.PopularEvents(report, await TournamentNameAsync(tournamentId));
return File(bytes, ReportExcelExporter.MimeType, $"PopularEvents_{tournamentId}.xlsx");
}
[HttpGet]
public async Task<IActionResult> RegistrationByGenderExcel(int tournamentId, int? zoneId)
{
var report = await _reportService.GetRegistrationByGenderAsync(tournamentId, zoneId);
var bytes = ReportExcelExporter.RegistrationByGender(report, await TournamentNameAsync(tournamentId));
return File(bytes, ReportExcelExporter.MimeType, $"RegistrationByGender_{tournamentId}.xlsx");
}
[HttpGet]
public async Task<IActionResult> EventSchoolReportExcel(int tournamentId, int? eventId, int? eventLevelId)
{
var report = await _reportService.GetEventSchoolReportAsync(tournamentId, eventId, eventLevelId);
var bytes = ReportExcelExporter.EventSchoolReport(report, await TournamentNameAsync(tournamentId));
return File(bytes, ReportExcelExporter.MimeType, $"EventSchoolReport_{tournamentId}.xlsx");
}
[HttpGet]
public async Task<IActionResult> StudentsBySchoolExcel(int tournamentId, int? schoolId, int? zoneId)
{
var report = await _reportService.GetStudentsBySchoolAsync(tournamentId, schoolId, zoneId);
var bytes = ReportExcelExporter.StudentsBySchool(report, await TournamentNameAsync(tournamentId));
return File(bytes, ReportExcelExporter.MimeType, $"StudentsBySchool_{tournamentId}.xlsx");
}
[HttpGet]
public async Task<IActionResult> ScoresByEventExcel(int tournamentId, int? eventId, int? eventLevelId)
{
var report = await _reportService.GetScoresByEventAsync(tournamentId, eventId, eventLevelId);
var bytes = ReportExcelExporter.ScoresByEvent(report, await TournamentNameAsync(tournamentId));
return File(bytes, ReportExcelExporter.MimeType, $"ScoresByEvent_{tournamentId}.xlsx");
}
[HttpGet]
public async Task<IActionResult> StudentPointsExcel(int tournamentId, int? schoolId, int? zoneId)
{
var report = await _reportService.GetStudentPointsAsync(tournamentId, schoolId, zoneId);
var bytes = ReportExcelExporter.StudentPoints(report, await TournamentNameAsync(tournamentId));
return File(bytes, ReportExcelExporter.MimeType, $"StudentPoints_{tournamentId}.xlsx");
}
[HttpGet]
public async Task<IActionResult> SchoolStandingsExcel(int tournamentId)
{
var standings = await _scoringService.GetSchoolStandingsAsync(tournamentId);
var bytes = ReportExcelExporter.SchoolStandings(standings, await TournamentNameAsync(tournamentId));
return File(bytes, ReportExcelExporter.MimeType, $"SchoolStandings_{tournamentId}.xlsx");
}
}

View File

@@ -4,6 +4,7 @@ using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums;
using SportsDivision.Domain.Interfaces;
using SportsDivision.Web.Helpers;
namespace SportsDivision.Web.Controllers;
@@ -19,7 +20,7 @@ public class SchoolController : Controller
_unitOfWork = unitOfWork;
}
public async Task<IActionResult> Index(int? zoneId, SchoolLevel? level)
public async Task<IActionResult> Index(int? zoneId, SchoolLevel? level, int page = 1, int pageSize = PaginationHelper.PageSize)
{
IEnumerable<SchoolDto> schools;
@@ -40,7 +41,7 @@ public class SchoolController : Controller
ViewBag.SelectedZoneId = zoneId;
ViewBag.SelectedLevel = level;
return View(schools);
return View(this.Page(schools, page, pageSize));
}
public async Task<IActionResult> Details(int id)
@@ -174,6 +175,12 @@ public class SchoolController : Controller
private async Task PopulateZonesViewBag()
{
var zones = await _unitOfWork.Zones.GetAllAsync();
ViewBag.Zones = zones;
ViewBag.Zones = zones.Select(z => new ZoneDto
{
ZoneId = z.ZoneId,
Name = z.Name,
Code = z.Code,
IsActive = z.IsActive
});
}
}

View File

@@ -20,6 +20,7 @@ public class ScoringConfigController : Controller
{
var constants = await _scoringService.GetScoringConstantsAsync();
var placementConfigs = await _scoringService.GetPlacementPointConfigsAsync();
ViewBag.ScoringConstants = constants;
ViewBag.PlacementPointConfigs = placementConfigs;
return View(constants);
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Web.Helpers;
namespace SportsDivision.Web.Controllers;
@@ -17,7 +18,7 @@ public class StudentController : Controller
_schoolService = schoolService;
}
public async Task<IActionResult> Index(int? schoolId, string? search)
public async Task<IActionResult> Index(int? schoolId, string? search, int page = 1, int pageSize = PaginationHelper.PageSize)
{
IEnumerable<StudentDto> students;
@@ -38,7 +39,7 @@ public class StudentController : Controller
ViewBag.SelectedSchoolId = schoolId;
ViewBag.SearchTerm = search;
return View(students);
return View(this.Page(students, page, pageSize));
}
public async Task<IActionResult> Details(int id)

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums;
using SportsDivision.Web.Helpers;
namespace SportsDivision.Web.Controllers;
@@ -18,7 +19,7 @@ public class TournamentController : Controller
_eventService = eventService;
}
public async Task<IActionResult> Index(TournamentStatus? status, bool showArchived = false)
public async Task<IActionResult> Index(TournamentStatus? status, bool showArchived = false, int page = 1, int pageSize = PaginationHelper.PageSize)
{
var tournaments = await _tournamentService.GetAllAsync(includeArchived: showArchived);
@@ -30,7 +31,7 @@ public class TournamentController : Controller
ViewBag.SelectedStatus = status;
ViewBag.ShowArchived = showArchived;
return View(tournaments);
return View(this.Page(tournaments, page, pageSize));
}
public async Task<IActionResult> Details(int id)
@@ -45,6 +46,8 @@ public class TournamentController : Controller
var eventLevels = await _tournamentService.GetEventLevelsAsync(id);
ViewBag.EventLevels = eventLevels;
ViewBag.Events = await _eventService.GetAllAsync();
ViewBag.Levels = await _tournamentService.GetAllEventLevelsAsync();
return View(tournament);
}
@@ -158,32 +161,6 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Index));
}
[HttpGet]
public async Task<IActionResult> SetupEventLevels(int id)
{
try
{
var tournament = await _tournamentService.GetByIdAsync(id);
if (tournament == null)
{
return NotFound();
}
var eventLevels = await _tournamentService.GetEventLevelsAsync(id);
var events = await _eventService.GetAllAsync();
ViewBag.Tournament = tournament;
ViewBag.Events = events;
ViewBag.EventLevels = eventLevels;
return View(eventLevels);
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddEventLevel(TournamentEventLevelCreateDto dto)
@@ -202,12 +179,12 @@ public class TournamentController : Controller
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(SetupEventLevels), new { id = dto.TournamentId });
return RedirectToAction(nameof(Details), new { id = dto.TournamentId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveEventLevel(int tournamentEventLevelId, int tournamentId)
public async Task<IActionResult> RemoveEventLevel(int tournamentEventLevelId, int id)
{
try
{
@@ -223,7 +200,7 @@ public class TournamentController : Controller
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(SetupEventLevels), new { id = tournamentId });
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
@@ -291,7 +268,7 @@ public class TournamentController : Controller
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleAgeWaiver(int tournamentEventLevelId, int tournamentId)
public async Task<IActionResult> ToggleAgeWaiver(int tournamentEventLevelId, int id)
{
try
{
@@ -307,6 +284,6 @@ public class TournamentController : Controller
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(SetupEventLevels), new { id = tournamentId });
return RedirectToAction(nameof(Details), new { id });
}
}

View File

@@ -12,22 +12,49 @@ public class TrackEventController : Controller
private readonly IHeatManagementService _heatManagementService;
private readonly ITournamentService _tournamentService;
private readonly IScoringService _scoringService;
private readonly IRegistrationService _registrationService;
public TrackEventController(
IHeatManagementService heatManagementService,
ITournamentService tournamentService,
IScoringService scoringService)
IScoringService scoringService,
IRegistrationService registrationService)
{
_heatManagementService = heatManagementService;
_tournamentService = tournamentService;
_scoringService = scoringService;
_registrationService = registrationService;
}
[HttpGet]
public async Task<IActionResult> Index(int tournamentEventLevelId)
{
if (tournamentEventLevelId <= 0)
{
ViewBag.ScoringTargets = await _tournamentService.GetEventLevelsByCategoryAsync(EventCategory.Track);
ViewBag.ScoringController = "TrackEvent";
ViewBag.ScoringTitle = "Track Event Scoring";
return View("SelectEventLevel");
}
var rounds = await _heatManagementService.GetRoundsAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId);
if (tel != null)
{
ViewBag.EventName = tel.EventName;
ViewBag.LevelName = tel.EventLevelName;
ViewBag.TournamentName = tel.TournamentName;
ViewBag.RegistrationCount = tel.RegistrationCount;
}
// Final standings (placement points are awarded from the final round).
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.Standings = registrations
.Where(r => r.Score?.Placement != null)
.OrderBy(r => r.Score!.Placement)
.ToList();
return View(rounds);
}
@@ -40,6 +67,10 @@ public class TrackEventController : Controller
return NotFound();
}
ViewBag.TournamentEventLevelId = round.TournamentEventLevelId;
var rounds = await _heatManagementService.GetRoundsAsync(round.TournamentEventLevelId);
ViewBag.HasNextRound = rounds.Any(r => r.RoundOrder == round.RoundOrder + 1);
return View(round);
}
@@ -65,11 +96,13 @@ public class TrackEventController : Controller
}
[HttpPost]
public async Task<IActionResult> SaveTimes(int heatId, [FromBody] List<HeatLaneUpdateDto> lanes)
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveTimes(int heatId, int roundId, List<HeatLaneUpdateDto> lanes)
{
var recordedBy = User.Identity?.Name ?? "Unknown";
await _heatManagementService.SaveHeatTimesAsync(heatId, lanes, recordedBy);
return Json(new { success = true });
TempData["SuccessMessage"] = "Heat times saved.";
return RedirectToAction(nameof(ManageRound), new { roundId });
}
[HttpPost]
@@ -84,8 +117,17 @@ public class TrackEventController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> PopulateNextRound(int roundId)
{
await _heatManagementService.PopulateNextRoundAsync(roundId);
return RedirectToAction(nameof(ManageRound), new { roundId });
var round = await _heatManagementService.GetRoundWithHeatsAsync(roundId);
try
{
await _heatManagementService.PopulateNextRoundAsync(roundId);
TempData["SuccessMessage"] = "Advancing athletes moved into the next round.";
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = round?.TournamentEventLevelId });
}
[HttpPost]
@@ -103,4 +145,26 @@ public class TrackEventController : Controller
await _heatManagementService.CompleteRoundAsync(roundId);
return RedirectToAction(nameof(ManageRound), new { roundId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CalculateFinalStandings(int tournamentEventLevelId)
{
try
{
var recordedBy = User.Identity?.Name ?? "Unknown";
await _scoringService.CalculateTrackFinalResultsAsync(tournamentEventLevelId, recordedBy);
TempData["SuccessMessage"] = "Final standings calculated and placement points awarded.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
namespace SportsDivision.Web.Helpers;
public static class PaginationHelper
{
public const int PageSize = 20;
/// <summary>Page-size choices offered in the UI. 0 means "All".</summary>
public static readonly int[] PageSizeOptions = { 20, 50, 100, 500, 0 };
/// <summary>
/// Slices <paramref name="source"/> to the requested page and records paging
/// metadata in ViewData (read as ViewBag.Page / ViewBag.TotalPages / ViewBag.PageSize
/// in views). A <paramref name="pageSize"/> of 0 (or less) means "show all".
/// </summary>
public static List<T> Page<T>(this Controller controller, IEnumerable<T> source, int page, int pageSize = PageSize)
{
var list = source as IList<T> ?? source.ToList();
// Only the offered choices are honoured; anything else falls back to the default.
if (!PageSizeOptions.Contains(pageSize)) pageSize = PageSize;
var showAll = pageSize <= 0;
var totalPages = showAll ? 1 : (int)Math.Ceiling(list.Count / (double)pageSize);
if (page < 1) page = 1;
if (totalPages > 0 && page > totalPages) page = totalPages;
controller.ViewData["Page"] = page;
controller.ViewData["TotalPages"] = totalPages;
controller.ViewData["TotalItems"] = list.Count;
controller.ViewData["PageSize"] = pageSize; // 0 == All
if (showAll) return list.ToList();
return list.Skip((page - 1) * pageSize).Take(pageSize).ToList();
}
}

View File

@@ -0,0 +1,192 @@
using ClosedXML.Excel;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.DTOs.ReportDtos;
namespace SportsDivision.Web.Reports;
/// <summary>
/// Builds Microsoft Excel (.xlsx) workbooks for each report. One worksheet per
/// report, with a title, subtitle (tournament name), a styled header row, and
/// flattened data rows for the nested reports.
/// </summary>
public static class ReportExcelExporter
{
public const string MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static int WriteHeader(IXLWorksheet ws, string title, string subtitle, string[] columns)
{
ws.Cell(1, 1).Value = title;
ws.Cell(1, 1).Style.Font.Bold = true;
ws.Cell(1, 1).Style.Font.FontSize = 14;
ws.Range(1, 1, 1, Math.Max(1, columns.Length)).Merge();
ws.Cell(2, 1).Value = subtitle;
ws.Cell(2, 1).Style.Font.Italic = true;
ws.Range(2, 1, 2, Math.Max(1, columns.Length)).Merge();
const int headerRow = 4;
for (int i = 0; i < columns.Length; i++)
{
var c = ws.Cell(headerRow, i + 1);
c.Value = columns[i];
c.Style.Font.Bold = true;
c.Style.Fill.BackgroundColor = XLColor.FromHtml("#003366");
c.Style.Font.FontColor = XLColor.White;
}
return headerRow + 1;
}
private static byte[] Finish(XLWorkbook wb, params IXLWorksheet[] sheets)
{
foreach (var ws in sheets)
{
ws.SheetView.FreezeRows(4);
ws.Columns().AdjustToContents();
}
using var ms = new MemoryStream();
wb.SaveAs(ms);
return ms.ToArray();
}
private static void SetInt(IXLCell cell, int? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; }
private static void SetDec(IXLCell cell, decimal? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; }
public static byte[] PopularEvents(IEnumerable<PopularEventsReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Popular Events");
int r = WriteHeader(ws, "Popular Events Report", tournamentName,
new[] { "Event", "Category", "Total", "Male", "Female" });
foreach (var e in data)
{
ws.Cell(r, 1).Value = e.EventName;
ws.Cell(r, 2).Value = e.Category;
ws.Cell(r, 3).Value = e.RegistrationCount;
ws.Cell(r, 4).Value = e.MaleCount;
ws.Cell(r, 5).Value = e.FemaleCount;
r++;
}
return Finish(wb, ws);
}
public static byte[] RegistrationByGender(IEnumerable<RegistrationByGenderReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Registration by Gender");
int r = WriteHeader(ws, "Registration by Gender", tournamentName,
new[] { "Event", "Level", "Male", "Female", "Total" });
foreach (var e in data)
{
ws.Cell(r, 1).Value = e.EventName;
ws.Cell(r, 2).Value = e.EventLevelName;
ws.Cell(r, 3).Value = e.MaleCount;
ws.Cell(r, 4).Value = e.FemaleCount;
ws.Cell(r, 5).Value = e.TotalCount;
r++;
}
return Finish(wb, ws);
}
public static byte[] EventSchoolReport(IEnumerable<EventSchoolReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Event School Report");
int r = WriteHeader(ws, "Event School Report", tournamentName,
new[] { "Event", "Level", "School", "Student", "Performance", "Placement", "Points" });
foreach (var ev in data)
foreach (var school in ev.Schools)
foreach (var s in school.Students)
{
ws.Cell(r, 1).Value = ev.EventName;
ws.Cell(r, 2).Value = ev.EventLevelName;
ws.Cell(r, 3).Value = school.SchoolName;
ws.Cell(r, 4).Value = s.StudentName;
SetDec(ws.Cell(r, 5), s.RawPerformance);
SetInt(ws.Cell(r, 6), s.Placement);
ws.Cell(r, 7).Value = s.PlacementPoints;
r++;
}
return Finish(wb, ws);
}
public static byte[] StudentsBySchool(IEnumerable<StudentsBySchoolReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Students by School");
int r = WriteHeader(ws, "Students by School", tournamentName,
new[] { "School", "Zone", "Student", "Sex", "Events" });
foreach (var school in data)
foreach (var s in school.Students)
{
ws.Cell(r, 1).Value = school.SchoolName;
ws.Cell(r, 2).Value = school.ZoneName;
ws.Cell(r, 3).Value = s.StudentName;
ws.Cell(r, 4).Value = s.Sex;
ws.Cell(r, 5).Value = string.Join(", ", s.Events);
r++;
}
return Finish(wb, ws);
}
public static byte[] ScoresByEvent(IEnumerable<ScoresByEventReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Scores by Event");
int r = WriteHeader(ws, "Scores by Event", tournamentName,
new[] { "Event", "Level", "Category", "Placement", "Student", "School", "Performance", "WA Points", "Placement Points" });
foreach (var ev in data)
foreach (var s in ev.Scores)
{
ws.Cell(r, 1).Value = ev.EventName;
ws.Cell(r, 2).Value = ev.EventLevelName;
ws.Cell(r, 3).Value = ev.Category;
SetInt(ws.Cell(r, 4), s.Placement);
ws.Cell(r, 5).Value = s.StudentName;
ws.Cell(r, 6).Value = s.SchoolName;
ws.Cell(r, 7).Value = s.RawPerformance;
ws.Cell(r, 8).Value = s.CalculatedPoints;
ws.Cell(r, 9).Value = s.PlacementPoints;
r++;
}
return Finish(wb, ws);
}
public static byte[] StudentPoints(IEnumerable<StudentPointsReportDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Student Points");
int r = WriteHeader(ws, "Student Points", tournamentName,
new[] { "Student", "School", "Sex", "Total Placement Points", "Events" });
foreach (var s in data)
{
ws.Cell(r, 1).Value = s.StudentName;
ws.Cell(r, 2).Value = s.SchoolName;
ws.Cell(r, 3).Value = s.Sex;
ws.Cell(r, 4).Value = s.TotalPlacementPoints;
ws.Cell(r, 5).Value = s.EventCount;
r++;
}
return Finish(wb, ws);
}
public static byte[] SchoolStandings(IEnumerable<SchoolPointsSummaryDto> data, string tournamentName)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("School Standings");
int r = WriteHeader(ws, "School Standings", tournamentName,
new[] { "Rank", "School", "Short Name", "Total Points", "1st Place", "2nd Place", "3rd Place" });
int rank = 1;
foreach (var s in data)
{
ws.Cell(r, 1).Value = rank++;
ws.Cell(r, 2).Value = s.SchoolName;
ws.Cell(r, 3).Value = s.ShortName ?? string.Empty;
ws.Cell(r, 4).Value = s.TotalPoints;
ws.Cell(r, 5).Value = s.FirstPlaceCount;
ws.Cell(r, 6).Value = s.SecondPlaceCount;
ws.Cell(r, 7).Value = s.ThirdPlaceCount;
r++;
}
return Finish(wb, ws);
}
}

View File

@@ -11,6 +11,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="QuestPDF" Version="2024.*" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">

View File

@@ -0,0 +1,41 @@
@using SportsDivision.Domain.Enums
@model EventCreateDto
@{
ViewData["Title"] = "Add Event";
}
<h2>Add Event</h2>
<hr />
<div class="row">
<div class="col-md-6">
<form asp-action="Create" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label asp-for="Name" class="form-label">Name</label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Category" class="form-label">Category</label>
<select asp-for="Category" asp-items="Html.GetEnumSelectList<EventCategory>()" class="form-select"></select>
</div>
<div class="form-check mb-2">
<input asp-for="IsRelay" class="form-check-input" />
<label asp-for="IsRelay" class="form-check-label">Relay event</label>
</div>
<div class="form-check mb-2">
<input asp-for="PrimarySchool" class="form-check-input" />
<label asp-for="PrimarySchool" class="form-check-label">Available to primary schools</label>
</div>
<div class="form-check mb-3">
<input asp-for="SecondarySchool" class="form-check-input" />
<label asp-for="SecondarySchool" class="form-check-label">Available to secondary schools</label>
</div>
<button type="submit" class="btn btn-primary">Create</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
@section Scripts { <partial name="_ValidationScriptsPartial" /> }

View File

@@ -0,0 +1,50 @@
@using SportsDivision.Domain.Enums
@model EventUpdateDto
@{
ViewData["Title"] = "Edit Event";
}
<h2>Edit Event</h2>
<hr />
<div class="row">
<div class="col-md-6">
<form asp-action="Edit" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<input type="hidden" asp-for="EventId" />
@for (var i = 0; i < Model.EventLevelIds.Count; i++)
{
<input type="hidden" asp-for="EventLevelIds[i]" />
}
<div class="mb-3">
<label asp-for="Name" class="form-label">Name</label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Category" class="form-label">Category</label>
<select asp-for="Category" asp-items="Html.GetEnumSelectList<EventCategory>()" class="form-select"></select>
</div>
<div class="form-check mb-2">
<input asp-for="IsRelay" class="form-check-input" />
<label asp-for="IsRelay" class="form-check-label">Relay event</label>
</div>
<div class="form-check mb-2">
<input asp-for="PrimarySchool" class="form-check-input" />
<label asp-for="PrimarySchool" class="form-check-label">Available to primary schools</label>
</div>
<div class="form-check mb-2">
<input asp-for="SecondarySchool" class="form-check-input" />
<label asp-for="SecondarySchool" class="form-check-label">Available to secondary schools</label>
</div>
<div class="form-check mb-3">
<input asp-for="IsActive" class="form-check-input" />
<label asp-for="IsActive" class="form-check-label">Active</label>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
@section Scripts { <partial name="_ValidationScriptsPartial" /> }

View File

@@ -35,3 +35,5 @@
</div>
}
</div>
<partial name="_Pagination" />

View File

@@ -50,6 +50,7 @@
<td>@r.SchoolName</td>
<td>
<form asp-action="RecordScore" method="post" class="d-flex gap-1">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<input type="hidden" name="EventRegistrationId" value="@r.EventRegistrationId" />
<input type="number" step="0.01" name="RawPerformance" value="@r.Score?.RawPerformance" class="form-control form-control-sm" style="width:100px" />
<button type="submit" class="btn btn-sm btn-outline-primary">Save</button>

View File

@@ -73,6 +73,7 @@
@for (int i = 1; i <= 3; i++)
{
<form asp-action="RecordAttempt" method="post" class="d-inline">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<input type="hidden" name="HighJumpHeightId" value="@height.HighJumpHeightId" />
<input type="hidden" name="EventRegistrationId" value="@reg.EventRegistrationId" />
<input type="hidden" name="AttemptNumber" value="@i" />

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Event School Report";
}
<h2>Event School Report</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Event School Report</h2>
<div class="d-flex gap-2">
<a asp-action="EventSchoolReportExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="EventSchoolReportPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr />
@foreach (var evt in Model)

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Popular Events";
}
<h2>Popular Events Report</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Popular Events Report</h2>
<div class="d-flex gap-2">
<a asp-action="PopularEventsExcel" asp-route-tournamentId="@ViewBag.TournamentId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="PopularEventsPdf" asp-route-tournamentId="@ViewBag.TournamentId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr />
<table class="table table-striped table-hover">

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Registration by Gender";
}
<h2>Registration by Gender</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Registration by Gender</h2>
<div class="d-flex gap-2">
<a asp-action="RegistrationByGenderExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="RegistrationByGenderPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr />
<table class="table table-striped table-hover">

View File

@@ -3,7 +3,10 @@
ViewData["Title"] = "School Standings";
}
<h2>School Standings</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">School Standings</h2>
<a asp-action="SchoolStandingsExcel" asp-route-tournamentId="@ViewBag.TournamentId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
</div>
<hr />
<div class="card shadow-sm">

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Scores by Event";
}
<h2>Scores by Event</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Scores by Event</h2>
<div class="d-flex gap-2">
<a asp-action="ScoresByEventExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="ScoresByEventPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr />
@foreach (var evt in Model)

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Student Points";
}
<h2>Student Points</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Student Points</h2>
<div class="d-flex gap-2">
<a asp-action="StudentPointsExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="StudentPointsPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr />
<table class="table table-striped table-hover">

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Students by School";
}
<h2>Students by School</h2>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Students by School</h2>
<div class="d-flex gap-2">
<a asp-action="StudentsBySchoolExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="StudentsBySchoolPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr />
@foreach (var school in Model)

View File

@@ -0,0 +1,45 @@
@model SchoolDto
@{
ViewData["Title"] = Model.Name;
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>@Model.Name</h2>
<div class="d-flex gap-2">
<a asp-action="Edit" asp-route-id="@Model.SchoolId" class="btn btn-outline-primary">Edit</a>
<a asp-controller="Student" asp-action="Index" asp-route-schoolId="@Model.SchoolId" class="btn btn-outline-secondary">Students</a>
<a asp-action="Index" class="btn btn-secondary">Back</a>
</div>
</div>
<partial name="_Notification" />
<div class="card shadow-sm" style="max-width: 600px;">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Short Name</dt>
<dd class="col-sm-8">@(string.IsNullOrEmpty(Model.ShortName) ? "—" : Model.ShortName)</dd>
<dt class="col-sm-4">Level</dt>
<dd class="col-sm-8">@Model.SchoolLevel</dd>
<dt class="col-sm-4">Zone</dt>
<dd class="col-sm-8">@Model.ZoneName</dd>
<dt class="col-sm-4">Students</dt>
<dd class="col-sm-8">@Model.StudentCount</dd>
<dt class="col-sm-4">Status</dt>
<dd class="col-sm-8">
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</dd>
</dl>
</div>
</div>

View File

@@ -61,3 +61,5 @@
}
</tbody>
</table>
<partial name="_Pagination" />

View File

@@ -16,6 +16,15 @@
<h5 class="mb-0">Scoring Constants (World Athletics)</h5>
</div>
<div class="card-body p-0">
@if (constants != null)
{
@foreach (var c in constants)
{
<form id="scf-@c.ScoringConstantId" asp-action="UpdateScoringConstant" method="post" class="d-none">
<input type="hidden" name="ScoringConstantId" value="@c.ScoringConstantId" />
</form>
}
}
<table class="table table-hover mb-0">
<thead><tr><th>Event</th><th>A</th><th>B</th><th>C</th><th>Unit</th><th>Action</th></tr></thead>
<tbody>
@@ -24,15 +33,12 @@
@foreach (var c in constants)
{
<tr>
<form asp-action="UpdateScoringConstant" method="post">
<input type="hidden" name="ScoringConstantId" value="@c.ScoringConstantId" />
<td>@c.EventName</td>
<td><input type="number" step="0.00001" name="A" value="@c.A" class="form-control form-control-sm" style="width:100px" /></td>
<td><input type="number" step="0.1" name="B" value="@c.B" class="form-control form-control-sm" style="width:80px" /></td>
<td><input type="number" step="0.01" name="C" value="@c.C" class="form-control form-control-sm" style="width:80px" /></td>
<td>@c.Unit</td>
<td><button type="submit" class="btn btn-sm btn-outline-primary">Save</button></td>
</form>
<td>@c.EventName</td>
<td><input type="number" step="0.00001" name="A" value="@c.A" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:100px" /></td>
<td><input type="number" step="0.1" name="B" value="@c.B" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
<td><input type="number" step="0.01" name="C" value="@c.C" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
<td>@c.Unit</td>
<td><button type="submit" form="scf-@c.ScoringConstantId" class="btn btn-sm btn-outline-primary">Save</button></td>
</tr>
}
}
@@ -47,6 +53,16 @@
<h5 class="mb-0">Placement Points</h5>
</div>
<div class="card-body p-0">
@if (placements != null)
{
@foreach (var p in placements.OrderBy(x => x.Placement))
{
<form id="ppf-@p.PlacementPointConfigId" asp-action="UpdatePlacementPointConfig" method="post" class="d-none">
<input type="hidden" name="PlacementPointConfigId" value="@p.PlacementPointConfigId" />
<input type="hidden" name="Placement" value="@p.Placement" />
</form>
}
}
<table class="table table-hover mb-0">
<thead><tr><th>Place</th><th>Points</th><th>Action</th></tr></thead>
<tbody>
@@ -55,13 +71,9 @@
@foreach (var p in placements.OrderBy(x => x.Placement))
{
<tr>
<form asp-action="UpdatePlacementPointConfig" method="post">
<input type="hidden" name="PlacementPointConfigId" value="@p.PlacementPointConfigId" />
<input type="hidden" name="Placement" value="@p.Placement" />
<td>#@p.Placement</td>
<td><input type="number" name="Points" value="@p.Points" class="form-control form-control-sm" style="width:60px" /></td>
<td><button type="submit" class="btn btn-sm btn-outline-primary">Save</button></td>
</form>
<td>#@p.Placement</td>
<td><input type="number" name="Points" value="@p.Points" form="ppf-@p.PlacementPointConfigId" class="form-control form-control-sm" style="width:60px" /></td>
<td><button type="submit" form="ppf-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-primary">Save</button></td>
</tr>
}
}

View File

@@ -0,0 +1,49 @@
@{
var title = ViewBag.ScoringTitle as string ?? "Scoring";
var controller = ViewBag.ScoringController as string ?? "TrackEvent";
var targets = ViewBag.ScoringTargets as IEnumerable<TournamentEventLevelDto> ?? Enumerable.Empty<TournamentEventLevelDto>();
ViewData["Title"] = title;
}
<h2>@title</h2>
<p class="text-muted">Select an event level to score.</p>
<hr />
@if (!targets.Any())
{
<div class="alert alert-info">
No event levels are available for scoring yet. Add event levels to an active tournament from the
<a asp-controller="Tournament" asp-action="Index">Tournaments</a> page.
</div>
}
else
{
@foreach (var grp in targets.GroupBy(t => new { t.TournamentId, t.TournamentName }))
{
<div class="card shadow-sm mb-3">
<div class="card-header text-white" style="background-color: #003366;">
<h5 class="mb-0">@grp.Key.TournamentName</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr><th>Event</th><th>Level</th><th>Registrations</th><th></th></tr>
</thead>
<tbody>
@foreach (var t in grp.OrderBy(x => x.EventName).ThenBy(x => x.EventLevelName))
{
<tr>
<td>@t.EventName</td>
<td>@t.EventLevelName</td>
<td>@t.RegistrationCount</td>
<td class="text-end">
<a asp-controller="@controller" asp-action="Index" asp-route-tournamentEventLevelId="@t.TournamentEventLevelId" class="btn btn-sm btn-outline-success">Score</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}

View File

@@ -0,0 +1,82 @@
@{
var pg = ViewBag.Page as int? ?? 1;
var totalPages = ViewBag.TotalPages as int? ?? 0;
var totalItems = ViewBag.TotalItems as int? ?? 0;
var pageSize = ViewBag.PageSize as int? ?? 20; // 0 == All
var request = ViewContext.HttpContext.Request;
// Preserve existing query-string filters (zoneId, level, search, etc.), swapping out
// "page" and "pageSize".
var baseParams = request.Query
.Where(kv => kv.Key != "page" && kv.Key != "pageSize")
.SelectMany(kv => kv.Value.Select(v => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(v ?? string.Empty)}"))
.ToList();
Func<int, int, string> url = (p, ps) =>
{
var ps2 = new List<string>(baseParams) { $"pageSize={ps}", $"page={p}" };
return $"{request.Path}?{string.Join("&", ps2)}";
};
Func<int, string> pageUrl = p => url(p, pageSize);
var prevHref = pg <= 1 ? "#" : pageUrl(pg - 1);
var nextHref = pg >= totalPages ? "#" : pageUrl(pg + 1);
var start = Math.Max(1, pg - 2);
var end = Math.Min(totalPages, pg + 2);
var firstItem = totalItems == 0 ? 0 : (pageSize <= 0 ? 1 : (pg - 1) * pageSize + 1);
var lastItem = pageSize <= 0 ? totalItems : Math.Min(pg * pageSize, totalItems);
}
@if (totalItems > 0)
{
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-3">
<div class="d-flex align-items-center gap-2">
<span class="small text-muted">Show</span>
<select class="form-select form-select-sm" style="width:auto" onchange="location.href=this.value" aria-label="Items per page">
@foreach (var opt in new[] { 20, 50, 100, 500 })
{
<option value="@url(1, opt)" selected="@(pageSize == opt)">@opt</option>
}
<option value="@url(1, 0)" selected="@(pageSize <= 0)">All</option>
</select>
<span class="small text-muted">per page</span>
</div>
<span class="small text-muted">Showing @firstItem@lastItem of @totalItems</span>
</div>
@if (totalPages > 1)
{
<nav aria-label="Page navigation" class="mt-2">
<ul class="pagination justify-content-center mb-0">
<li class="page-item @(pg <= 1 ? "disabled" : "")">
<a class="page-link" href="@prevHref">Previous</a>
</li>
@if (start > 1)
{
<li class="page-item"><a class="page-link" href="@pageUrl(1)">1</a></li>
@if (start > 2)
{
<li class="page-item disabled"><span class="page-link">…</span></li>
}
}
@for (int i = start; i <= end; i++)
{
<li class="page-item @(i == pg ? "active" : "")">
<a class="page-link" href="@pageUrl(i)">@i</a>
</li>
}
@if (end < totalPages)
{
@if (end < totalPages - 1)
{
<li class="page-item disabled"><span class="page-link">…</span></li>
}
<li class="page-item"><a class="page-link" href="@pageUrl(totalPages)">@totalPages</a></li>
}
<li class="page-item @(pg >= totalPages ? "disabled" : "")">
<a class="page-link" href="@nextHref">Next</a>
</li>
</ul>
</nav>
}
}

View File

@@ -56,3 +56,5 @@
}
</tbody>
</table>
<partial name="_Pagination" />

View File

@@ -6,12 +6,25 @@
var levels = ViewBag.Levels as IEnumerable<EventLevelDto>;
}
@{
var statusClass = Model.Status switch {
TournamentStatus.Draft => "bg-secondary",
TournamentStatus.Registration => "bg-info",
TournamentStatus.InProgress => "bg-primary",
TournamentStatus.Completed => "bg-success",
_ => "bg-dark"
};
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2>@Model.Name</h2>
<h2 class="mb-1">
@Model.Name
<span class="badge @statusClass align-middle">@Model.Status</span>
@if (Model.IsArchived) { <span class="badge bg-dark align-middle">Archived</span> }
</h2>
<p class="text-muted mb-0">@Model.StartDate.ToString("MMMM d") - @Model.EndDate.ToString("MMMM d, yyyy")</p>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 flex-wrap justify-content-end">
@if (Model.Status == TournamentStatus.Draft)
{
<form asp-action="UpdateStatus" method="post">
@@ -27,16 +40,53 @@
<input type="hidden" name="status" value="InProgress" />
<button type="submit" class="btn btn-primary">Start Tournament</button>
</form>
<form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="Draft" />
<button type="submit" class="btn btn-outline-secondary"
onclick="return confirm('Revert this tournament back to Draft?')">Back to Draft</button>
</form>
}
@if (Model.Status == TournamentStatus.InProgress)
{
<form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="Completed" />
<button type="submit" class="btn btn-success">Complete</button>
<button type="submit" class="btn btn-success"
onclick="return confirm('Mark &quot;@Model.Name&quot; as Completed?\n\nYou can reopen it later if this was a mistake.')">Complete</button>
</form>
<form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="Registration" />
<button type="submit" class="btn btn-outline-secondary"
onclick="return confirm('Move this tournament back to Registration?')">Back to Registration</button>
</form>
}
@if (Model.Status == TournamentStatus.Completed)
{
<form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="InProgress" />
<button type="submit" class="btn btn-warning"
onclick="return confirm('Reopen this completed tournament and set it back to In Progress?')">Reopen Tournament</button>
</form>
}
<a asp-action="Edit" asp-route-id="@Model.TournamentId" class="btn btn-outline-primary">Edit</a>
@if (!Model.IsArchived)
{
<form asp-action="Archive" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<button type="submit" class="btn btn-outline-dark"
onclick="return confirm('Archive this tournament? It will be hidden from the default list but not deleted, and can be restored later.')">Archive</button>
</form>
}
else
{
<form asp-action="Unarchive" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<button type="submit" class="btn btn-outline-dark">Unarchive</button>
</form>
}
</div>
</div>
@@ -76,7 +126,15 @@
</form>
</td>
<td>
<a asp-controller="TrackEvent" asp-action="Index" asp-route-tournamentEventLevelId="@tel.TournamentEventLevelId" class="btn btn-sm btn-outline-success">Scoring</a>
@{
var scoringController = tel.Category switch
{
EventCategory.Field => "FieldEvent",
EventCategory.HighJump => "HighJump",
_ => "TrackEvent"
};
}
<a asp-controller="@scoringController" asp-action="Index" asp-route-tournamentEventLevelId="@tel.TournamentEventLevelId" class="btn btn-sm btn-outline-success">Scoring</a>
<form asp-action="RemoveEventLevel" method="post" class="d-inline">
<input type="hidden" name="tournamentEventLevelId" value="@tel.TournamentEventLevelId" />
<input type="hidden" name="id" value="@Model.TournamentId" />

View File

@@ -1,6 +1,8 @@
@model IEnumerable<TournamentDto>
@{
ViewData["Title"] = "Tournaments";
var selectedStatus = ViewBag.SelectedStatus as TournamentStatus?;
var showArchived = ViewBag.ShowArchived as bool? ?? false;
}
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -10,6 +12,26 @@
<partial name="_Notification" />
<form asp-action="Index" method="get" class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label mb-0 small text-muted">Status</label>
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All statuses</option>
@foreach (var s in Enum.GetValues<TournamentStatus>())
{
<option value="@s" selected="@(selectedStatus == s)">@s</option>
}
</select>
</div>
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="showArchived" value="true"
id="showArchived" @(showArchived ? "checked" : "") onchange="this.form.submit()" />
<label class="form-check-label" for="showArchived">Show archived</label>
</div>
</div>
</form>
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -45,8 +67,25 @@
<td>
<a asp-action="Edit" asp-route-id="@t.TournamentId" class="btn btn-sm btn-outline-primary">Edit</a>
<a asp-action="Details" asp-route-id="@t.TournamentId" class="btn btn-sm btn-outline-info">Details</a>
@if (!t.IsArchived)
{
<form asp-action="Archive" method="post" class="d-inline">
<input type="hidden" name="id" value="@t.TournamentId" />
<button type="submit" class="btn btn-sm btn-outline-dark"
onclick="return confirm('Archive &quot;@t.Name&quot;? It will be hidden from the default list but can be restored.')">Archive</button>
</form>
}
else
{
<form asp-action="Unarchive" method="post" class="d-inline">
<input type="hidden" name="id" value="@t.TournamentId" />
<button type="submit" class="btn btn-sm btn-outline-dark">Unarchive</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
<partial name="_Pagination" />

View File

@@ -4,13 +4,29 @@
var telId = ViewBag.TournamentEventLevelId as int?;
var eventName = ViewBag.EventName as string;
var levelName = ViewBag.LevelName as string;
var tournamentName = ViewBag.TournamentName as string;
var regCount = ViewBag.RegistrationCount as int? ?? 0;
var standings = ViewBag.Standings as IEnumerable<EventRegistrationDto> ?? Enumerable.Empty<EventRegistrationDto>();
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2>Track Event Scoring</h2>
<p class="text-muted">@eventName - @levelName</p>
<h2 class="mb-1">@(string.IsNullOrEmpty(eventName) ? "Track Event Scoring" : $"{eventName} — {levelName}")</h2>
<p class="text-muted mb-0">
@if (!string.IsNullOrEmpty(tournamentName)) { <span><i class="bi bi-trophy"></i> @tournamentName</span> }
<span class="ms-2"><i class="bi bi-people"></i> @regCount registered</span>
<span class="ms-2"><i class="bi bi-stopwatch"></i> @Model.Count() round(s)</span>
</p>
</div>
@if (Model.Any(r => r.RoundType == RoundType.Final))
{
<form asp-action="CalculateFinalStandings" method="post">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<button type="submit" class="btn btn-warning" title="Rank the final-round times and award placement points">
<i class="bi bi-trophy"></i> Calculate Final Standings
</button>
</form>
}
</div>
<partial name="_Notification" />
@@ -18,31 +34,50 @@
<div class="card shadow-sm mb-3">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Rounds</h5>
<form asp-action="CreateRound" method="post" class="d-flex gap-2">
<form asp-action="CreateRound" method="post" class="d-flex gap-2 align-items-center">
<input type="hidden" name="TournamentEventLevelId" value="@telId" />
<select name="RoundType" class="form-select form-select-sm" style="width:120px">
<option value="Heats">Heats</option>
<select name="RoundType" id="roundTypeSelect" class="form-select form-select-sm" style="width:120px">
<option value="Heat">Heats</option>
<option value="SemiFinal">Semi-Final</option>
<option value="Final">Final</option>
</select>
<input type="number" name="RoundOrder" value="@(Model.Count() + 1)" class="form-control form-control-sm" style="width:60px" />
<input type="number" name="AdvanceTopN" value="3" class="form-control form-control-sm" style="width:60px" placeholder="Top N" />
<input type="number" name="AdvanceFastestLosers" value="2" class="form-control form-control-sm" style="width:60px" placeholder="FL" />
<input type="number" name="RoundOrder" value="@(Model.Count() + 1)" class="form-control form-control-sm" style="width:60px" title="Round order" />
<input type="number" name="AdvanceTopN" id="advanceTopN" value="3" class="form-control form-control-sm advance-field" style="width:60px" placeholder="Top N" title="Advance top N per heat" />
<input type="number" name="AdvanceFastestLosers" id="advanceFL" value="2" class="form-control form-control-sm advance-field" style="width:60px" placeholder="FL" title="Fastest losers" />
<span id="finalNote" class="text-light small fst-italic" style="display:none;">no advancement</span>
<button type="submit" class="btn btn-sm btn-success">Add Round</button>
</form>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead><tr><th>Round</th><th>Type</th><th>Status</th><th>Heats</th><th>Advance</th><th>Actions</th></tr></thead>
<thead><tr><th>Round</th><th>Type</th><th>Status</th><th>Heats</th><th>Competitors</th><th>Advances to next round</th><th>Actions</th></tr></thead>
<tbody>
@foreach (var r in Model.OrderBy(r => r.RoundOrder))
{
var competitors = r.Heats.Sum(h => h.HeatLanes.Count);
var isFinal = r.RoundType == RoundType.Final;
<tr>
<td><strong>Round @r.RoundOrder</strong></td>
<td><span class="badge bg-info">@r.RoundType</span></td>
<td><span class="badge @(r.Status == RoundStatus.Completed ? "bg-success" : r.Status == RoundStatus.InProgress ? "bg-primary" : "bg-secondary")">@r.Status</span></td>
<td>@r.Heats.Count heats</td>
<td>Top @(r.AdvanceTopN ?? 0) + @(r.AdvanceFastestLosers ?? 0) FL</td>
<td>@r.Heats.Count</td>
<td>@competitors</td>
<td>
@if (isFinal)
{
<span class="badge bg-warning text-dark">Final — points awarded</span>
}
else if ((r.AdvanceTopN ?? 0) > 0 || (r.AdvanceFastestLosers ?? 0) > 0)
{
<span title="Top @(r.AdvanceTopN ?? 0) per heat plus @(r.AdvanceFastestLosers ?? 0) fastest losers overall">
Top @(r.AdvanceTopN ?? 0)/heat + @(r.AdvanceFastestLosers ?? 0) fastest losers
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
<a asp-action="ManageRound" asp-route-roundId="@r.RoundId" class="btn btn-sm btn-primary">Manage</a>
@if (r.Heats.Count == 0)
@@ -59,4 +94,63 @@
</tbody>
</table>
</div>
<div class="card-footer text-muted small">
Athletes run preliminary heats (max 8 per heat); the fastest advance to the Final.
<strong>Placement points are awarded from the Final round only.</strong>
</div>
</div>
@if (standings.Any())
{
<div class="card shadow-sm mb-3">
<div class="card-header text-white" style="background-color:#003366;">
<h5 class="mb-0">Final Standings</h5>
</div>
<div class="card-body p-0">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr><th>Place</th><th>Athlete</th><th>School</th><th>Time (s)</th><th>Points</th></tr>
</thead>
<tbody>
@foreach (var r in standings)
{
<tr>
<td>
@if (r.Score!.Placement <= 3) { <span class="badge bg-warning text-dark">#@r.Score.Placement</span> }
else { <span>@r.Score.Placement</span> }
</td>
<td>@r.StudentName</td>
<td>@r.SchoolName</td>
<td>@r.Score.RawPerformance.ToString("0.00")</td>
<td><strong>@r.Score.PlacementPoints</strong></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@section Scripts {
<script>
(function () {
var sel = document.getElementById('roundTypeSelect');
if (!sel) return;
var topN = document.getElementById('advanceTopN');
var fl = document.getElementById('advanceFL');
var note = document.getElementById('finalNote');
function sync() {
var isFinal = sel.value === 'Final';
// Finals don't advance anyone, so hide the advancement inputs.
[topN, fl].forEach(function (el) {
if (!el) return;
el.style.display = isFinal ? 'none' : '';
el.disabled = isFinal;
});
if (note) note.style.display = isFinal ? '' : 'none';
}
sel.addEventListener('change', sync);
sync();
})();
</script>
}

View File

@@ -16,12 +16,19 @@
<input type="hidden" name="roundId" value="@Model.RoundId" />
<button type="submit" class="btn btn-warning" onclick="return confirm('Calculate advancement?')">Calculate Advancement</button>
</form>
@if (ViewBag.HasNextRound == true)
{
<form asp-action="PopulateNextRound" method="post">
<input type="hidden" name="roundId" value="@Model.RoundId" />
<button type="submit" class="btn btn-info" onclick="return confirm('Move the advancing athletes into the next round?')">Send Advancers to Next Round</button>
</form>
}
<form asp-action="CompleteRound" method="post">
<input type="hidden" name="roundId" value="@Model.RoundId" />
<button type="submit" class="btn btn-success" onclick="return confirm('Complete this round?')">Complete Round</button>
</form>
}
<a asp-action="Index" asp-route-tournamentEventLevelId="@telId" class="btn btn-secondary">Back</a>
<a asp-action="Index" asp-route-tournamentEventLevelId="@Model.TournamentEventLevelId" class="btn btn-secondary">Back</a>
</div>
</div>
@@ -54,6 +61,7 @@
<div class="card-body">
<form asp-action="SaveTimes" method="post">
<input type="hidden" name="heatId" value="@heat.HeatId" />
<input type="hidden" name="roundId" value="@Model.RoundId" />
<table class="table table-hover">
<thead>
<tr>