Files
chapter-organizer/WebApp/Components/Features/Students/StudentImport.razor
T
poprhythmandCursor 4cfd85b902 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>
2026-08-29 23:55:03 -04:00

302 lines
10 KiB
Plaintext

@page "/students/import"
@page "/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Parsers
@using Core.Services
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@inject AppDbContext Context
@inject IStudentNotesImportService NotesImportService
@inject IStudentNotesImportSaveService NotesSaveService
@inject INotesService NotesService
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<StudentImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Students"
Description="Add new students from CSV. Existing first+last names are skipped; leftover columns merge into student notes."
ShowBackButton="true"
BackButtonUrl="/students" />
<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>Student Name</code>, <code>Grade</code>, <code>TSA year</code>.
Optional IDs are saved on new students. Every other column is merged into that student's notes.
</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 (_students 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">
@_students.Length student(s) parsed.
@_newStudentCount new, @_existingStudentCount already in the database.
</MudAlert>
@if (_leftoverFieldNames.Count > 0)
{
<MudText Typo="Typo.body2">Leftover note fields: @string.Join(", ", _leftoverFieldNames)</MudText>
}
else
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">No leftover note fields in this file.</MudText>
}
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _students.Length == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private Student[]? _students;
private List<string> _leftoverFieldNames = [];
private int _newStudentCount;
private int _existingStudentCount;
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 student 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));
_students = new StudentParser(reader).Parse();
_leftoverFieldNames = PeekLeftoverFieldNames(_fileBytes);
var existingNames = await Context.Students
.AsNoTracking()
.Select(s => new { s.FirstName, s.LastName })
.ToListAsync(token);
var existingSet = existingNames
.Select(s => (s.FirstName, s.LastName))
.ToHashSet();
_existingStudentCount = _students.Count(s => existingSet.Contains((s.FirstName, s.LastName)));
_newStudentCount = _students.Length - _existingStudentCount;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing student CSV");
_students = null;
_leftoverFieldNames = [];
_parseError = $"Error parsing CSV: {ex.Message}";
if (!_isDisposed)
Snackbar.Add(_parseError, Severity.Error);
}
finally
{
_isParsing = false;
}
}
private async Task HandleSave()
{
if (_students is null || _fileBytes 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 student in _students)
{
token.ThrowIfCancellationRequested();
var exists = await Context.Students
.FirstOrDefaultAsync(e => e.FirstName == student.FirstName && e.LastName == student.LastName, token);
if (exists != null)
continue;
await Context.Students.AddAsync(student, token);
added++;
}
await Context.SaveChangesAsync(token);
var notesCreated = 0;
var notesUpdated = 0;
if (_leftoverFieldNames.Count > 0)
{
var students = await Context.Students
.AsNoTracking()
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(token);
var notes = await NotesService.GetStudentNotesAsync(students.Select(s => s.Id));
var existing = notes.ToDictionary(k => k.Key, v => v.Value.Content);
await using var csvStream = new MemoryStream(_fileBytes, writable: false);
var parseResult = NotesImportService.Parse(csvStream, students, existing);
if (parseResult.IsSuccess && parseResult.StudentsWithChanges > 0)
{
var saveResult = await NotesSaveService.SaveAsync(parseResult, token);
notesCreated = saveResult.NotesCreated;
notesUpdated = saveResult.NotesUpdated;
}
}
if (_isDisposed)
return;
Snackbar.Add(
$"Added {added} student(s). Notes created {notesCreated}, updated {notesUpdated}.",
Severity.Success);
NavigationManager.NavigateTo("/students");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported students");
if (!_isDisposed)
Snackbar.Add($"Error saving students: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
ResetParse();
}
private void ResetParse()
{
_students = null;
_leftoverFieldNames = [];
_newStudentCount = 0;
_existingStudentCount = 0;
_parseError = null;
}
private static List<string> PeekLeftoverFieldNames(byte[] csvBytes)
{
using var reader = new StreamReader(new MemoryStream(csvBytes));
using var parser = new StudentNotesFieldParser(reader);
return parser.PeekLeftoverFieldNames();
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}