@page "/students/event-ranking/import" @attribute [Authorize] @implements IAsyncDisposable @using Core.Models @using Core.Services @using Microsoft.EntityFrameworkCore @using WebApp.Models @inject IStudentEventRankingImportService ImportService @inject IStudentEventRankingSaveService SaveService @inject AppDbContext Context @inject NavigationManager NavigationManager @inject ISnackbar Snackbar @inject IDialogService DialogService @inject ILogger Logger @rendermode InteractiveServer Upload CSV Required column: Student Name. Rank columns are 1 through 10. Names can be Last, First or First Last. Event cells can be a full name, short name, or a close match. @if (!string.IsNullOrEmpty(_fileName)) { @_fileName } Parse Clear Parsed Results @if (_isParsing) { Parsing... } else if (_parseResult == null) { Upload and parse a CSV to see results here } else { @foreach (var error in _parseResult.Errors) { @error } @foreach (var warning in _parseResult.Warnings) { @warning } @if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0) { Matched @_parseResult.TotalParsed ranking(s) for @_parseResult.StudentsWithAcceptedRanks.Count student(s) } @if (_parseResult.Issues.Count > 0) { Row Rank Type Student Event text Message @context.RowNumber @(context.Rank > 0 ? context.Rank.ToString() : "-") @context.IssueType @context.RawStudentName @context.RawEventName @context.Message } @if (_parseResult.IsSuccess && _parseResult.Matches.Count > 0) { Matched rankings Student Rank CSV text Matched event Score @context.Ranking.Student.FirstNameLastName @context.Ranking.Rank @context.RawEventName @context.Ranking.EventDefinition.Name @context.EventScore Save to Database Clear Results } } @code { private byte[]? _fileBytes; private string? _fileName; private StudentEventRankingParseResult? _parseResult; private bool _isParsing; private bool _isSaving; private CancellationTokenSource? _cancellationTokenSource; private bool _isDisposed; protected override void OnInitialized() { _cancellationTokenSource = new CancellationTokenSource(); } private async Task HandleFileChanged(InputFileChangeEventArgs args) { if (_isDisposed) return; try { await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024); await using var memory = new MemoryStream(); await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None); _fileBytes = memory.ToArray(); _fileName = args.File.Name; _parseResult = null; } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { Logger.LogError(ex, "Error reading ranking CSV"); if (!_isDisposed) Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error); } } private async Task HandleParse() { if (_fileBytes is null) { Snackbar.Add("Please choose a CSV file first", Severity.Warning); return; } _isParsing = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var students = await Context.Students .AsNoTracking() .OrderBy(s => s.LastName) .ThenBy(s => s.FirstName) .ToListAsync(token); var events = await Context.Events .AsNoTracking() .OrderBy(e => e.Name) .ToListAsync(token); await using var stream = new MemoryStream(_fileBytes, writable: false); _parseResult = ImportService.Parse(stream, students, events); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { Logger.LogError(ex, "Error parsing ranking CSV"); if (!_isDisposed) { Snackbar.Add($"Error parsing CSV: {ex.Message}", Severity.Error); _parseResult = new StudentEventRankingParseResult { Errors = { $"Error: {ex.Message}" } }; } } finally { _isParsing = false; } } private void HandleClear() { _fileBytes = null; _fileName = null; _parseResult = null; } private void HandleClearResults() { _parseResult = null; } private async Task HandleSave() { if (_parseResult is null || !_parseResult.IsSuccess || _parseResult.TotalParsed == 0) { Snackbar.Add("No valid rankings to save", Severity.Warning); return; } try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var existing = await SaveService.GetStudentsWithExistingRankingsAsync(_parseResult, token); if (existing.Count > 0) { var preview = string.Join(", ", existing.Take(8)); var remaining = existing.Count > 8 ? $" and {existing.Count - 8} more" : string.Empty; var confirmed = await DialogService.ShowMessageBox( "Replace existing rankings?", $"This will replace current rankings for {existing.Count} student(s): {preview}{remaining}. Continue?", yesText: "Replace rankings", cancelText: "Cancel"); if (confirmed != true || _isDisposed) return; } _isSaving = true; var saveResult = await SaveService.SaveAsync(_parseResult, token); if (_isDisposed) return; Snackbar.Add( $"Saved {saveResult.RankingsSaved} ranking(s) for {saveResult.StudentsUpdated} student(s)", Severity.Success); NavigationManager.NavigateTo("/students/event-ranking"); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { Logger.LogError(ex, "Error saving imported rankings"); if (!_isDisposed) Snackbar.Add($"Error saving rankings: {ex.Message}", Severity.Error); } finally { _isSaving = false; } } private static Color GetIssueTypeColor(StudentEventRankingIssueType issueType) => issueType switch { StudentEventRankingIssueType.UnmatchedStudent => Color.Error, StudentEventRankingIssueType.UnmatchedEvent => Color.Warning, StudentEventRankingIssueType.AmbiguousEvent => Color.Warning, StudentEventRankingIssueType.DuplicateEvent => Color.Info, StudentEventRankingIssueType.DuplicateRank => Color.Info, StudentEventRankingIssueType.InvalidFormat => Color.Error, _ => Color.Default }; public async ValueTask DisposeAsync() { if (!_isDisposed) { _isDisposed = true; _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; } await ValueTask.CompletedTask; } }