feat: import leftover student fields into notes and show them on the roster

Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 23:55:03 -04:00
co-authored by Cursor
parent 4c91db37c2
commit 4cfd85b902
39 changed files with 2437 additions and 166 deletions
@@ -0,0 +1,253 @@
@page "/events/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Parsers
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models
@inject AppDbContext Context
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<EventCatalogImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Catalog"
Description="Add new event definitions from CSV. Existing event names are skipped."
Icon="@AppIcons.Events"
ShowBackButton="true"
BackButtonUrl="/events" />
<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 columns: <code>Event</code>, <code>Team Size</code>, <code>State Count</code>.
Optional: <code>Short Name</code>, <code>EventFormat</code>, <code>Level of Effort</code>,
<code>Eligibility</code>, <code>Description</code>, <code>Theme</code>,
<code>Documentation</code>, <code>State Presubmission</code>,
<code>Semifinalist Activity</code>, <code>Regional Notes</code>.
</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 (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_events is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_events.Length event(s) parsed.
@_newEventCount new, @_existingEventCount already in the database.
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _newEventCount == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private EventDefinition[]? _events;
private int _newEventCount;
private int _existingEventCount;
private string? _parseError;
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;
ResetParse();
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reading event catalog 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;
_parseError = null;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
using var reader = new StreamReader(new MemoryStream(_fileBytes));
_events = new EventDefinitionParser(reader).Parse();
var existingNames = await Context.Events
.AsNoTracking()
.Select(e => e.Name)
.ToListAsync(token);
var existingSet = existingNames.ToHashSet();
_existingEventCount = _events.Count(e => existingSet.Contains(e.Name));
_newEventCount = _events.Length - _existingEventCount;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing event catalog CSV");
_events = null;
_parseError = $"Error parsing CSV: {ex.Message}";
if (!_isDisposed)
Snackbar.Add(_parseError, Severity.Error);
}
finally
{
_isParsing = false;
}
}
private async Task HandleSave()
{
if (_events is null)
{
Snackbar.Add("Parse a CSV first", Severity.Warning);
return;
}
_isSaving = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var added = 0;
foreach (var evt in _events)
{
token.ThrowIfCancellationRequested();
var exists = await Context.Events.FirstOrDefaultAsync(e => e.Name == evt.Name, token);
if (exists != null)
continue;
await Context.Events.AddAsync(evt, token);
added++;
}
await Context.SaveChangesAsync(token);
if (_isDisposed)
return;
Snackbar.Add($"Added {added} event(s).", Severity.Success);
NavigationManager.NavigateTo("/events");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported events");
if (!_isDisposed)
Snackbar.Add($"Error saving events: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
ResetParse();
}
private void ResetParse()
{
_events = null;
_newEventCount = 0;
_existingEventCount = 0;
_parseError = null;
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -4,6 +4,7 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using WebApp.Authentication
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@@ -13,6 +14,11 @@
<MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Add new catalog events from CSV. Existing names are skipped.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/events/import" Variant="Variant.Outlined">Import</MudButton>
</MudTooltip>
</AuthorizeView>
<MudTooltip Text="Printable Descriptions">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
</MudTooltip>