feat: add CSV import for student event rankings

Let advisors load preference ranks from a converted CSV with fuzzy matching, known aliases, and a parse-preview-save page instead of relying on the ranking editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 23:41:13 -04:00
co-authored by Cursor
parent 2acd83a841
commit 4c91db37c2
13 changed files with 1156 additions and 59 deletions
@@ -11,6 +11,14 @@
Title="Student Event Ranks"
Icon="@AppIcons.EventRank">
<ActionButtons>
<MudTooltip Text="Import rankings from CSV">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile"
Href="students/event-ranking/import"
Variant="Variant.Filled"
Color="Color.Primary">
Import
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Event Ranking" />
</ActionButtons>
</PageHeader>
@@ -0,0 +1,345 @@
@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<EventRankingImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Rankings"
Description="Upload a CSV of student event preferences, preview matches, then save."
Icon="@AppIcons.EventRank"
ShowBackButton="true"
BackButtonUrl="/students/event-ranking" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required column: <code>Student Name</code>. Rank columns are <code>1</code> through <code>10</code>.
Names can be <code>Last, First</code> or <code>First Last</code>. Event cells can be a full name, short name, or a close match.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton
Variant="Variant.Text"
OnClick="HandleClear"
Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (_parseResult == null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
@foreach (var error in _parseResult.Errors)
{
<MudAlert Severity="Severity.Error" Dense="true">@error</MudAlert>
}
@foreach (var warning in _parseResult.Warnings)
{
<MudAlert Severity="Severity.Warning" Dense="true">@warning</MudAlert>
}
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
{
<MudAlert Severity="Severity.Success" Dense="true">
Matched @_parseResult.TotalParsed ranking(s) for @_parseResult.StudentsWithAcceptedRanks.Count student(s)
</MudAlert>
}
@if (_parseResult.Issues.Count > 0)
{
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@($"Issues ({_parseResult.Issues.Count})")"
Icon="@Icons.Material.Filled.Warning">
<MudTable Items="@_parseResult.Issues" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Row</MudTh>
<MudTh>Rank</MudTh>
<MudTh>Type</MudTh>
<MudTh>Student</MudTh>
<MudTh>Event text</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Row">@context.RowNumber</MudTd>
<MudTd DataLabel="Rank">@(context.Rank > 0 ? context.Rank.ToString() : "-")</MudTd>
<MudTd DataLabel="Type">
<MudChip T="string" Size="Size.Small" Color="@GetIssueTypeColor(context.IssueType)">
@context.IssueType
</MudChip>
</MudTd>
<MudTd DataLabel="Student">@context.RawStudentName</MudTd>
<MudTd DataLabel="Event text">@context.RawEventName</MudTd>
<MudTd DataLabel="Message">@context.Message</MudTd>
</RowTemplate>
</MudTable>
</MudExpansionPanel>
</MudExpansionPanels>
}
@if (_parseResult.IsSuccess && _parseResult.Matches.Count > 0)
{
<MudText Typo="Typo.h6">Matched rankings</MudText>
<MudTable Items="@_parseResult.Matches" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Student</MudTh>
<MudTh>Rank</MudTh>
<MudTh>CSV text</MudTh>
<MudTh>Matched event</MudTh>
<MudTh>Score</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Student">@context.Ranking.Student.FirstNameLastName</MudTd>
<MudTd DataLabel="Rank">@context.Ranking.Rank</MudTd>
<MudTd DataLabel="CSV text">@context.RawEventName</MudTd>
<MudTd DataLabel="Matched event">@context.Ranking.EventDefinition.Name</MudTd>
<MudTd DataLabel="Score">@context.EventScore</MudTd>
</RowTemplate>
</MudTable>
<MudStack Row="true" Spacing="2" Class="mt-2">
<MudButton
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@_isSaving">
Save to Database
</MudButton>
<MudButton
Variant="Variant.Text"
OnClick="HandleClearResults">
Clear Results
</MudButton>
</MudStack>
}
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@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;
}
}