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
@@ -2,17 +2,40 @@
@attribute [Authorize]
@implements IAsyncDisposable
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models
@using WebApp.Components.Shared.Components
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject INotesService NotesService
@inject IConfiguration Configuration
@inject IJSRuntime JSRuntime
@using Core.Notes
@using Core.Parsers
@using WebApp.Services
<PageHeader Title="Students">
<ActionButtons>
<MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudButtonGroup Variant="Variant.Outlined">
<MudTooltip Text="Add new students from CSV. Existing names are skipped; leftover columns merge into student notes.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/students/import">Import</MudButton>
</MudTooltip>
<MudMenu Icon="@Icons.Material.Filled.ArrowDropDown"
AriaLabel="More import actions"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight">
<MudMenuItem Icon="@Icons.Material.Filled.Download"
OnClick="DownloadStudentImportTemplate">
Download CSV template
</MudMenuItem>
</MudMenu>
</MudButtonGroup>
</AuthorizeView>
<MudTooltip Text="Event Rankings">
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
</MudTooltip>
@@ -24,6 +47,7 @@
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudDataGrid T="Student"
@key="NoteFieldColumnsKey"
ServerData="ServerReload"
@ref="_dataGrid"
Filterable="true"
@@ -65,6 +89,15 @@
<span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear)
</CellTemplate>
</PropertyColumn>
@foreach (var field in _noteFieldColumns)
{
var fieldName = field;
<TemplateColumn Title="@fieldName" Sortable="false" Filterable="false">
<CellTemplate>
@GetNoteField(context.Item.Id, fieldName)
</CellTemplate>
</TemplateColumn>
}
</Columns>
<PagerContent>
<MudDataGridPager T="Student"></MudDataGridPager>
@@ -77,12 +110,20 @@
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
private List<string> _noteFieldColumns = [];
private Dictionary<int, string?> _noteContentByStudentId = [];
private string NoteFieldColumnsKey => string.Join('\u001f', _noteFieldColumns);
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override void OnParametersSet()
{
_noteFieldColumns = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
}
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
{
if (_isDisposed)
@@ -104,6 +145,9 @@
var totalItems = await query.CountAsync(cancellationToken);
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
var notes = await NotesService.GetStudentNotesAsync(pagedData.Select(s => s.Id));
_noteContentByStudentId = notes.ToDictionary(k => k.Key, v => v.Value.Content);
return new GridData<Student>
{
TotalItems = totalItems,
@@ -160,6 +204,7 @@
return;
}
await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken);
Context.Students.Remove(studentToDelete);
await Context.SaveChangesAsync(cancellationToken);
@@ -203,4 +248,42 @@
}
await ValueTask.CompletedTask;
}
private async Task DownloadStudentImportTemplate()
{
if (_isDisposed)
return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var fromNotes = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
var leftover = _noteFieldColumns
.Concat(fromNotes)
.Distinct(StringComparer.OrdinalIgnoreCase);
var csv = StudentImportCsvTemplate.Build(leftover);
byte[] bytes = [..System.Text.Encoding.UTF8.GetPreamble(), ..System.Text.Encoding.UTF8.GetBytes(csv)];
var base64 = Convert.ToBase64String(bytes);
await JSRuntime.InvokeVoidAsync("tsaDownload.fromBase64", "student-import-template.csv", "text/csv;charset=utf-8", base64);
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not download template: {ex.Message}", Severity.Error);
}
}
private string GetNoteField(int studentId, string fieldName)
{
if (!_noteContentByStudentId.TryGetValue(studentId, out var content))
return string.Empty;
return ImportedFieldsTable.GetFieldValue(content, fieldName) ?? string.Empty;
}
}