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:
@@ -25,6 +25,7 @@
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||
<script src="js/markdownTablePaste.js"></script>
|
||||
<script src="js/downloadFile.js"></script>
|
||||
<script src="js/login.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
@using Core.Services
|
||||
@using PSC.Blazor.Components.MarkdownEditor
|
||||
@inject INotesService NotesService
|
||||
@inject INoteNamingService NoteNamingService
|
||||
@inject MarkdownTablePasteService MarkdownTablePasteService
|
||||
@inject ISnackbar Snackbar
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Notes</MudText>
|
||||
@if (_isLoading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (ReadOnly)
|
||||
{
|
||||
@if (string.IsNullOrWhiteSpace(_content))
|
||||
{
|
||||
<MudText Class="mud-text-secondary">No notes yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@((MarkupString)MarkdownHelper.ToHtml(_content))
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown is supported. Imported fields appear in a table and can be edited.</MudText>
|
||||
<MarkdownEditor Value="@_content"
|
||||
ValueChanged="@((string? value) => _content = value ?? string.Empty)"
|
||||
Placeholder="Student notes..."
|
||||
AutoSaveEnabled="false"
|
||||
NativeSpellChecker="false" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int StudentId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
private string _content = string.Empty;
|
||||
private int? _noteId;
|
||||
private int _loadedStudentId;
|
||||
private bool _isLoading = true;
|
||||
private bool _pasteInitialized;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (StudentId <= 0 || _loadedStudentId == StudentId)
|
||||
return;
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var note = await NotesService.GetStudentNoteAsync(StudentId);
|
||||
_noteId = note?.Id;
|
||||
_content = note?.Content ?? string.Empty;
|
||||
_loadedStudentId = StudentId;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (ReadOnly || _pasteInitialized)
|
||||
return;
|
||||
|
||||
await Task.Delay(150);
|
||||
await MarkdownTablePasteService.InitializeAsync();
|
||||
_pasteInitialized = true;
|
||||
}
|
||||
|
||||
public async Task SaveAsync()
|
||||
{
|
||||
if (StudentId <= 0)
|
||||
return;
|
||||
|
||||
if (_noteId is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_content))
|
||||
return;
|
||||
|
||||
var created = await NotesService.CreateNoteAsync(new Note
|
||||
{
|
||||
Title = NoteNamingService.GetStudentNoteTitle(StudentId),
|
||||
Content = _content
|
||||
});
|
||||
_noteId = created.Id;
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await NotesService.GetNoteAsync(_noteId.Value);
|
||||
if (existing is null)
|
||||
return;
|
||||
|
||||
if (string.Equals(existing.Content ?? string.Empty, _content, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
existing.Content = _content;
|
||||
await NotesService.UpdateNoteAsync(existing);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,10 @@
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||||
<StudentNotePanel StudentId="student.Id" ReadOnly="true" />
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private Student? student;
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
</MudSelect>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="5">
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
<StudentNotePanel @ref="_notePanel" StudentId="Student.Id" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</EditForm>
|
||||
|
||||
@@ -73,6 +78,7 @@
|
||||
private FormChangeTracker? _formChangeTracker;
|
||||
private EditContext? _editContext;
|
||||
private List<string> _validationErrors = new();
|
||||
private StudentNotePanel? _notePanel;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -120,6 +126,8 @@
|
||||
try
|
||||
{
|
||||
await Context.SaveChangesAsync();
|
||||
if (_notePanel is not null)
|
||||
await _notePanel.SaveAsync();
|
||||
Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
|
||||
_formChangeTracker?.AllowNavigation();
|
||||
NavigationManager.NavigateTo(ReturnUrl ?? "/students");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/settings/chapter"
|
||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||
@implements IAsyncDisposable
|
||||
@using WebApp.Authentication
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -7,12 +8,13 @@
|
||||
@using Core.Models
|
||||
@inject IConfiguration Configuration
|
||||
@inject IChapterSettingsWriter ChapterSettingsWriter
|
||||
@inject INotesService NotesService
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageHeader
|
||||
Title="Chapter Settings"
|
||||
Description="Configure chapter information. Changes take effect on next application restart." />
|
||||
Description="Configure chapter information. Student index columns apply the next time you open Students. Printouts that cache chapter name or year may still need a restart." />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
|
||||
@@ -90,6 +92,41 @@
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-6 mb-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Field names from each student's imported notes table to show as extra Students index columns. Click a field found in notes to add or remove it, or type names (one per line).
|
||||
</MudText>
|
||||
@if (_availableFields.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Fields in student notes</MudText>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" Class="mb-4">
|
||||
@foreach (var field in _availableFields)
|
||||
{
|
||||
var selected = IsFieldSelected(field);
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@(selected ? Color.Primary : Color.Default)"
|
||||
Variant="@(selected ? Variant.Filled : Variant.Outlined)"
|
||||
OnClick="() => ToggleField(field)">
|
||||
@field
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-3">
|
||||
No imported fields found in student notes yet.
|
||||
</MudText>
|
||||
}
|
||||
<MudTextField @bind-Value="_noteFieldsText"
|
||||
Label="Imported field columns"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="5"
|
||||
HelperText="Example: Interview Time" />
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-6">
|
||||
<MudGrid>
|
||||
<MudItem xs="12">
|
||||
@@ -125,14 +162,52 @@
|
||||
|
||||
@code {
|
||||
private Models.ChapterSettings? _settings;
|
||||
private string _noteFieldsText = string.Empty;
|
||||
private IReadOnlyList<string> _availableFields = [];
|
||||
private bool _isSaving;
|
||||
private string? _statusMessage;
|
||||
private Severity _statusSeverity = Severity.Success;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
||||
?? new Models.ChapterSettings();
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_settings = Models.ChapterSettings.FromConfiguration(Configuration);
|
||||
_noteFieldsText = string.Join(Environment.NewLine, _settings.StudentIndexNoteFields);
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
_availableFields = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> SelectedFields() =>
|
||||
_noteFieldsText
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
|
||||
private bool IsFieldSelected(string field) =>
|
||||
SelectedFields().Any(selected => selected.Equals(field, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void ToggleField(string field)
|
||||
{
|
||||
var selected = SelectedFields().ToList();
|
||||
var index = selected.FindIndex(name => name.Equals(field, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0)
|
||||
selected.RemoveAt(index);
|
||||
else
|
||||
selected.Add(field);
|
||||
|
||||
_noteFieldsText = string.Join(Environment.NewLine, selected);
|
||||
}
|
||||
|
||||
private async Task SaveSettings()
|
||||
@@ -144,18 +219,41 @@
|
||||
|
||||
try
|
||||
{
|
||||
await ChapterSettingsWriter.WriteAsync(_settings);
|
||||
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
||||
_settings.StudentIndexNoteFields = [.. SelectedFields()];
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
await ChapterSettingsWriter.WriteAsync(_settings, cancellationToken);
|
||||
if (_isDisposed)
|
||||
return;
|
||||
_statusMessage = "Settings saved. Open Students again to see the updated index columns.";
|
||||
_statusSeverity = Severity.Success;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
_statusMessage = $"Error saving settings: {ex.Message}";
|
||||
_statusSeverity = Severity.Error;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSaving = false;
|
||||
if (!_isDisposed)
|
||||
_isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
@page "/import"
|
||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||
@using Core.Parsers
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Authentication
|
||||
@inject AppDbContext Context
|
||||
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Import Data</PageTitle>
|
||||
|
||||
<h1>Import Data</h1>
|
||||
|
||||
<h3>Events</h3>
|
||||
<InputFile OnChange="UploadEvents"></InputFile>
|
||||
<text>@_events?.Length Events</text>
|
||||
<button class="btn btn-primary" @onclick="SaveEvents">Save to Database</button>
|
||||
<br/>
|
||||
|
||||
<h3>Students</h3>
|
||||
<InputFile OnChange="UploadStudents"></InputFile>
|
||||
<text>@_students?.Length Students</text>
|
||||
<button class="btn btn-primary" @onclick="SaveStudents">Save to Database</button>
|
||||
|
||||
@code {
|
||||
private EventDefinition[]? _events;
|
||||
private Student[]? _students;
|
||||
|
||||
async Task UploadEvents(InputFileChangeEventArgs arg)
|
||||
{
|
||||
await GetStreamReaderFromInputFile(arg, reader =>
|
||||
{
|
||||
var eventDefinitionParser = new EventDefinitionParser(reader);
|
||||
_events = eventDefinitionParser.Parse();
|
||||
});
|
||||
}
|
||||
|
||||
async Task SaveEvents()
|
||||
{
|
||||
if (_events == null)
|
||||
return;
|
||||
|
||||
foreach (var evt in _events)
|
||||
{
|
||||
// check if it already exists
|
||||
var exists
|
||||
= await Context.Events
|
||||
.FirstOrDefaultAsync(e => e.Name == evt.Name);
|
||||
if (exists != null)
|
||||
continue;
|
||||
await Context.Events.AddAsync(evt);
|
||||
}
|
||||
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
async Task UploadStudents(InputFileChangeEventArgs arg)
|
||||
{
|
||||
await GetStreamReaderFromInputFile(arg, reader =>
|
||||
{
|
||||
var studentParser = new StudentParser(reader);
|
||||
_students = studentParser.Parse();
|
||||
});
|
||||
}
|
||||
|
||||
async Task SaveStudents()
|
||||
{
|
||||
if (_students == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var student in _students)
|
||||
{
|
||||
// check if it already exists
|
||||
var exists
|
||||
= await Context.Students
|
||||
.FirstOrDefaultAsync(e
|
||||
=> e.FirstName == student.FirstName
|
||||
&& e.LastName == student.LastName);
|
||||
if (exists != null)
|
||||
continue;
|
||||
await Context.Students.AddAsync(student);
|
||||
}
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task GetStreamReaderFromInputFile(InputFileChangeEventArgs arg, Action<StreamReader> f)
|
||||
{
|
||||
StreamReader? streamReader = null;
|
||||
try
|
||||
{
|
||||
var browserFile = arg.File;
|
||||
|
||||
await using var fs = browserFile.OpenReadStream();
|
||||
await using var ms = new MemoryStream();
|
||||
|
||||
await fs.CopyToAsync(ms);
|
||||
ms.Seek(0,0);
|
||||
streamReader = new StreamReader(ms);
|
||||
f(streamReader);
|
||||
}
|
||||
catch
|
||||
{
|
||||
streamReader?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user