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>
290 lines
11 KiB
Plaintext
290 lines
11 KiB
Plaintext
@page "/students"
|
|
@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>
|
|
<MudTooltip Text="Registration">
|
|
<MudButton StartIcon="@AppIcons.Registration" Href="students/teams" Variant="Variant.Outlined">Registration</MudButton>
|
|
</MudTooltip>
|
|
</ActionButtons>
|
|
</PageHeader>
|
|
|
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
|
<MudDataGrid T="Student"
|
|
@key="NoteFieldColumnsKey"
|
|
ServerData="ServerReload"
|
|
@ref="_dataGrid"
|
|
Filterable="true"
|
|
RowsPerPage="25"
|
|
Dense="true"
|
|
Striped="true"
|
|
Hover="true"
|
|
Loading="@_isLoading"
|
|
LoadingProgressColor="Color.Primary">
|
|
<Columns>
|
|
<PropertyColumn Property="@(e => e.LastName)" Title="Name" Sortable="true">
|
|
<CellTemplate>
|
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.Wrap">
|
|
<MudLink Href="@($"/students/details?id={context.Item.Id}&returnUrl=/students")"
|
|
Underline="Underline.Hover"
|
|
Color="Color.Primary">
|
|
@context.Item.LastNameFirstName
|
|
</MudLink>
|
|
@if (context.Item.OfficerRole != null)
|
|
{
|
|
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
|
|
}
|
|
</MudStack>
|
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
|
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
|
TooltipText="Edit"
|
|
Href="@($"/students/edit?id={context.Item.Id}&returnUrl=/students")" />
|
|
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
|
TooltipText="Delete"
|
|
HoverColor="Color.Error"
|
|
OnClick="() => DeleteStudent(context.Item!)" />
|
|
</MudStack>
|
|
</MudStack>
|
|
</CellTemplate>
|
|
</PropertyColumn>
|
|
<PropertyColumn Property="@(e => e.Grade)" Title="Grade (TSA Year)" Sortable="true">
|
|
<CellTemplate>
|
|
<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>
|
|
</PagerContent>
|
|
</MudDataGrid>
|
|
</MudPaper>
|
|
|
|
@code {
|
|
MudDataGrid<Student> _dataGrid = null!;
|
|
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)
|
|
{
|
|
return new GridData<Student> { TotalItems = 0, Items = [] };
|
|
}
|
|
|
|
_isLoading = true;
|
|
try
|
|
{
|
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
|
|
var query =
|
|
Context.Students
|
|
.AsNoTracking()
|
|
.OrderBy(e => e.LastName)
|
|
.Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
|
|
|
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,
|
|
Items = pagedData
|
|
};
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return new GridData<Student> { TotalItems = 0, Items = [] };
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
return new GridData<Student> { TotalItems = 0, Items = [] };
|
|
}
|
|
finally
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
_isLoading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task DeleteStudent(Student student)
|
|
{
|
|
if (_isDisposed) return;
|
|
|
|
try
|
|
{
|
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
|
|
var result = await DialogService
|
|
.ShowMessageBox("Delete student",
|
|
(MarkupString)$"Are you sure want to delete <b>{student.Name}</b>? This cannot be undone.",
|
|
yesText:"Yes",
|
|
noText:"Cancel");
|
|
|
|
if (_isDisposed) return;
|
|
|
|
if (result == true)
|
|
{
|
|
// Load the student fresh from database with tracking to avoid tracking conflicts
|
|
var studentToDelete = await Context.Students
|
|
.FirstOrDefaultAsync(s => s.Id == student.Id, cancellationToken);
|
|
|
|
if (_isDisposed) return;
|
|
|
|
if (studentToDelete == null)
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add("Student not found or already deleted", Severity.Warning);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken);
|
|
Context.Students.Remove(studentToDelete);
|
|
await Context.SaveChangesAsync(cancellationToken);
|
|
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add($"Student {studentToDelete.Name} deleted", Severity.Info);
|
|
}
|
|
}
|
|
|
|
if (!_isDisposed)
|
|
{
|
|
StateHasChanged();
|
|
await _dataGrid.ReloadServerData();
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// Component was disposed, ignore
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// JS connection lost, ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add($"Error deleting student: {ex.Message}", Severity.Error);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
_isDisposed = true;
|
|
_cancellationTokenSource?.Cancel();
|
|
_cancellationTokenSource?.Dispose();
|
|
_cancellationTokenSource = null;
|
|
}
|
|
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;
|
|
}
|
|
}
|