Promote returning students, assign officers, and clear last season's data after an automatic SQLite backup, with Docker volume path docs fixed for /app/Data. Co-authored-by: Cursor <cursoragent@cursor.com>
652 lines
27 KiB
Plaintext
652 lines
27 KiB
Plaintext
@page "/settings/new-year"
|
|
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
|
@implements IAsyncDisposable
|
|
@using Core.Entities
|
|
@using Core.Models
|
|
@using Core.YearTransition
|
|
@using Data
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using WebApp.Authentication
|
|
@using WebApp.Components.Shared.Components
|
|
@using WebApp.Services
|
|
@inject AppDbContext Context
|
|
@inject IConfiguration Configuration
|
|
@inject IYearRolloverService YearRolloverService
|
|
@inject IDialogService DialogService
|
|
@inject ISnackbar Snackbar
|
|
@inject ILogger<YearRollover> Logger
|
|
|
|
@rendermode InteractiveServer
|
|
|
|
<PageHeader
|
|
Title="New Year Rollover"
|
|
Description="Promote returning students, set officers, and clear last season's data after an automatic database backup."
|
|
ShowBackButton="true"
|
|
BackButtonUrl="/settings/chapter" />
|
|
|
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 mb-8">
|
|
@if (_result != null)
|
|
{
|
|
<MudAlert Severity="Severity.Success" Class="mb-4" Variant="Variant.Filled">
|
|
Rollover to @_result.CompetitionYear completed successfully.
|
|
</MudAlert>
|
|
<MudPaper Class="pa-6 mb-4">
|
|
<MudText Typo="Typo.h5" Class="mb-3">Summary</MudText>
|
|
<MudText>Backup: <code>@_result.BackupPath</code></MudText>
|
|
<MudText>Students promoted: @_result.StudentsPromoted</MudText>
|
|
<MudText>Students removed: @_result.StudentsRemoved</MudText>
|
|
<MudText>Teams deleted: @_result.TeamsDeleted</MudText>
|
|
<MudText>Event rankings deleted: @_result.RankingsDeleted</MudText>
|
|
<MudText>Meeting histories deleted: @_result.MeetingHistoriesDeleted</MudText>
|
|
<MudText>Event occurrences deleted: @_result.EventOccurrencesDeleted</MudText>
|
|
<MudText Typo="Typo.subtitle1" Class="mt-3 mb-1">Officers</MudText>
|
|
<MudList T="string" Dense="true">
|
|
@foreach (var line in _result.OfficerSummary)
|
|
{
|
|
<MudListItem T="string" Icon="@Icons.Material.Filled.Badge">@line</MudListItem>
|
|
}
|
|
</MudList>
|
|
<MudAlert Severity="Severity.Info" Class="mt-4" Dense="true">
|
|
Restart the application so printouts and the home page show the new competition year.
|
|
Then add new students, import the new state schedule, and clear Meeting Schedule browser state with Reset.
|
|
</MudAlert>
|
|
</MudPaper>
|
|
}
|
|
else if (_students == null)
|
|
{
|
|
<MudProgressCircular Indeterminate="true" />
|
|
}
|
|
else if (!_wizardUnlocked)
|
|
{
|
|
<MudPaper Class="pa-6 mb-4">
|
|
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="mb-4" Icon="@Icons.Material.Filled.Lock">
|
|
This wizard permanently changes production chapter data. It is locked until you intentionally unlock it.
|
|
</MudAlert>
|
|
|
|
<MudText Typo="Typo.h5" Class="mb-3">Before you continue</MudText>
|
|
<MudText Typo="Typo.body2" Class="mb-3">
|
|
Unlocking lets you plan a rollover. Applying it will still require a second typed confirmation.
|
|
An automatic database backup is created immediately before apply and is the only undo.
|
|
</MudText>
|
|
|
|
<MudList T="string" Dense="true" Class="mb-4">
|
|
<MudListItem T="string" Icon="@Icons.Material.Filled.DeleteForever" IconColor="Color.Error">
|
|
Non-returning students are permanently deleted
|
|
</MudListItem>
|
|
<MudListItem T="string" Icon="@Icons.Material.Filled.Groups" IconColor="Color.Error">
|
|
All teams, event rankings, and meeting history are cleared
|
|
</MudListItem>
|
|
<MudListItem T="string" Icon="@Icons.Material.Filled.Event" IconColor="Color.Warning">
|
|
Event occurrences are cleared by default (state schedule)
|
|
</MudListItem>
|
|
</MudList>
|
|
|
|
<MudCheckBox @bind-Value="_ackDestructive" Color="Color.Error" Class="mb-2"
|
|
Label="I understand this permanently deletes students and season data" />
|
|
<MudCheckBox @bind-Value="_ackBackupOnlyUndo" Color="Color.Error" Class="mb-4"
|
|
Label="I understand the automatic backup is the only undo" />
|
|
|
|
<MudTextField @bind-Value="_unlockPhrase"
|
|
Label="@($"Type {UnlockPhrase} to unlock")"
|
|
Variant="Variant.Outlined"
|
|
HelperText="@($"Confirmation is case-insensitive. Type exactly: {UnlockPhrase}")"
|
|
Class="mb-4"
|
|
Style="max-width: 320px;"
|
|
Immediate="true" />
|
|
|
|
<MudButton Variant="Variant.Filled"
|
|
Color="Color.Error"
|
|
StartIcon="@Icons.Material.Filled.LockOpen"
|
|
Disabled="!CanUnlockWizard"
|
|
OnClick="UnlockWizard">
|
|
Unlock wizard
|
|
</MudButton>
|
|
</MudPaper>
|
|
}
|
|
else
|
|
{
|
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.LockOpen">
|
|
Wizard unlocked for this session. Close or refresh this page to lock it again.
|
|
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Default" Class="ml-2" OnClick="LockWizard">
|
|
Lock again
|
|
</MudButton>
|
|
</MudAlert>
|
|
|
|
<MudStepper @bind-ActiveIndex="Step" Class="mb-4">
|
|
<MudStep Title="Year">Year & grades</MudStep>
|
|
<MudStep Title="Roster">Returning roster</MudStep>
|
|
<MudStep Title="Officers">Officers</MudStep>
|
|
<MudStep Title="Reset">Season reset</MudStep>
|
|
<MudStep Title="Apply">Preview & apply</MudStep>
|
|
</MudStepper>
|
|
|
|
@if (Step == 0)
|
|
{
|
|
<MudPaper Class="pa-6 mb-4">
|
|
<MudText Typo="Typo.h5" Class="mb-3">Competition year</MudText>
|
|
<MudTextField @bind-Value="_targetYear"
|
|
Label="Target competition year"
|
|
Variant="Variant.Outlined"
|
|
HelperText="Defaults to current year + 1"
|
|
Class="mb-4"
|
|
Style="max-width: 200px;" />
|
|
|
|
<MudText Typo="Typo.h5" Class="mb-2">Chapter type</MudText>
|
|
@if (_configuredSchoolLevel is { } configured)
|
|
{
|
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
|
@GraduatingGradeResolver.Describe(configured, _graduatingGrade!.Value)
|
|
<MudLink Href="/settings/chapter" Class="ml-2">Change in Chapter Settings</MudLink>
|
|
</MudAlert>
|
|
}
|
|
else
|
|
{
|
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
|
School level is not set in Chapter Settings (Both MS and HS). Choose the chapter type for this rollover,
|
|
then set it permanently on the <MudLink Href="/settings/chapter">Chapter Settings</MudLink> page.
|
|
</MudAlert>
|
|
<MudSelect T="SchoolLevel?" Value="_overrideSchoolLevel" Label="Chapter type for this rollover"
|
|
Variant="Variant.Outlined" Class="mb-3" Style="max-width: 320px;"
|
|
ValueChanged="OnOverrideSchoolLevelChanged">
|
|
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.MiddleSchool">Middle School (graduate after grade 8)</MudSelectItem>
|
|
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.HighSchool">High School (graduate after grade 12)</MudSelectItem>
|
|
</MudSelect>
|
|
}
|
|
|
|
<MudAlert Severity="Severity.Normal" Dense="true" Class="mt-2">
|
|
Applying the rollover will create an automatic backup at
|
|
<code>Data/backups/pre-rollover-*.db</code> before making any changes.
|
|
That backup is the only undo.
|
|
</MudAlert>
|
|
</MudPaper>
|
|
}
|
|
else if (Step == 1)
|
|
{
|
|
<MudPaper Class="pa-6 mb-4">
|
|
<MudText Typo="Typo.h5" Class="mb-3">Returning students</MudText>
|
|
<MudText Typo="Typo.body2" Class="mb-3 mud-text-secondary">
|
|
Students at or above graduating grade @_graduatingGrade are unchecked by default.
|
|
Paste a list of names (one per line) to check matches.
|
|
</MudText>
|
|
|
|
<MudTextField @bind-Value="_pasteBox"
|
|
Label="Paste returning names (optional)"
|
|
Variant="Variant.Outlined"
|
|
Lines="4"
|
|
Class="mb-2" />
|
|
<MudButton Variant="Variant.Outlined" Size="Size.Small" Class="mb-4" OnClick="ApplyPastedNames"
|
|
StartIcon="@Icons.Material.Filled.ContentPaste">
|
|
Apply pasted names
|
|
</MudButton>
|
|
|
|
@if (_pasteUnmatched.Count > 0)
|
|
{
|
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
|
Unmatched: @string.Join("; ", _pasteUnmatched)
|
|
</MudAlert>
|
|
}
|
|
@if (_pasteAmbiguous.Count > 0)
|
|
{
|
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
|
Ambiguous (check manually): @string.Join("; ", _pasteAmbiguous)
|
|
</MudAlert>
|
|
}
|
|
|
|
<MudTable Items="_students" Dense="true" Hover="true" Striped="true">
|
|
<HeaderContent>
|
|
<MudTh>Returning</MudTh>
|
|
<MudTh>Name</MudTh>
|
|
<MudTh>Grade</MudTh>
|
|
<MudTh>TSA Year</MudTh>
|
|
<MudTh>Officer</MudTh>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd>
|
|
<MudCheckBox T="bool" Value="_returningIds.Contains(context.Id)"
|
|
ValueChanged="(bool v) => SetReturning(context.Id, v)"
|
|
Dense="true" Color="Color.Primary" />
|
|
</MudTd>
|
|
<MudTd>@context.LastNameFirstName</MudTd>
|
|
<MudTd>@context.Grade</MudTd>
|
|
<MudTd>@context.TsaYear</MudTd>
|
|
<MudTd>@(context.OfficerRole?.ToString() ?? "—")</MudTd>
|
|
</RowTemplate>
|
|
</MudTable>
|
|
<MudText Typo="Typo.caption" Class="mt-2">
|
|
@_returningIds.Count returning · @(_students.Count - _returningIds.Count) will be removed
|
|
</MudText>
|
|
</MudPaper>
|
|
}
|
|
else if (Step == 2)
|
|
{
|
|
<MudPaper Class="pa-6 mb-4">
|
|
<MudText Typo="Typo.h5" Class="mb-3">New officer slate</MudText>
|
|
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary">
|
|
Leave a role blank to leave that office vacant. Only returning students are listed.
|
|
New students who will be officers can be assigned later on the student edit page.
|
|
</MudText>
|
|
<MudGrid>
|
|
@foreach (var role in _officerRoles)
|
|
{
|
|
<MudItem xs="12" md="6">
|
|
<MudSelect T="int?" Value="GetOfficerSelection(role)"
|
|
ValueChanged="(int? id) => SetOfficerSelection(role, id)"
|
|
Label="@role.ToString()"
|
|
Variant="Variant.Outlined"
|
|
Clearable="true">
|
|
@foreach (var student in ReturningStudents)
|
|
{
|
|
<MudSelectItem T="int?" Value="@student.Id">@student.LastNameFirstName</MudSelectItem>
|
|
}
|
|
</MudSelect>
|
|
</MudItem>
|
|
}
|
|
</MudGrid>
|
|
</MudPaper>
|
|
}
|
|
else if (Step == 3)
|
|
{
|
|
<MudPaper Class="pa-6 mb-4">
|
|
<MudText Typo="Typo.h5" Class="mb-3">Season reset</MudText>
|
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
|
The following are always cleared: all teams, all event rankings, and all meeting history records.
|
|
Written notes on the Notes page are not affected.
|
|
</MudAlert>
|
|
<MudCheckBox @bind-Value="_clearEventOccurrences" Color="Color.Primary" Label="Clear all event occurrences (state schedule)" />
|
|
<MudText Typo="Typo.caption" Class="mud-text-secondary">
|
|
Leave this checked unless you plan to keep last year's calendar rows. Import the new schedule afterward.
|
|
</MudText>
|
|
</MudPaper>
|
|
}
|
|
else if (Step == 4)
|
|
{
|
|
var plan = BuildPlan();
|
|
<MudPaper Class="pa-6 mb-4">
|
|
<MudText Typo="Typo.h5" Class="mb-3">Preview</MudText>
|
|
<MudText>Competition year → <strong>@plan.TargetCompetitionYear</strong></MudText>
|
|
<MudText>Promote <strong>@plan.ReturningCount</strong> students · Remove <strong>@plan.RemovalCount</strong> students</MudText>
|
|
<MudText>Clear teams, rankings, meeting history@( _clearEventOccurrences ? ", and event occurrences" : "" )</MudText>
|
|
|
|
@if (plan.Warnings.Count > 0)
|
|
{
|
|
<MudAlert Severity="Severity.Warning" Class="mt-3 mb-2">
|
|
<MudText Typo="Typo.subtitle2">Warnings</MudText>
|
|
<ul class="mb-0">
|
|
@foreach (var warning in plan.Warnings)
|
|
{
|
|
<li>@warning</li>
|
|
}
|
|
</ul>
|
|
</MudAlert>
|
|
}
|
|
|
|
<MudExpansionPanels Class="mt-3 mb-3">
|
|
<MudExpansionPanel Text="@($"Promotions ({plan.ReturningCount})")">
|
|
<MudTable Items="plan.Promotions" Dense="true" Hover="true">
|
|
<HeaderContent>
|
|
<MudTh>Name</MudTh>
|
|
<MudTh>Grade</MudTh>
|
|
<MudTh>TSA Year</MudTh>
|
|
<MudTh>Officer</MudTh>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd>@context.Student.LastNameFirstName</MudTd>
|
|
<MudTd>@context.PreviousGrade → @context.NewGrade</MudTd>
|
|
<MudTd>@context.PreviousTsaYear → @context.NewTsaYear</MudTd>
|
|
<MudTd>@(context.PreviousOfficerRole?.ToString() ?? "—") → @(context.NewOfficerRole?.ToString() ?? "—")</MudTd>
|
|
</RowTemplate>
|
|
</MudTable>
|
|
</MudExpansionPanel>
|
|
<MudExpansionPanel Text="@($"Removals ({plan.RemovalCount})")">
|
|
<MudList T="string" Dense="true">
|
|
@foreach (var student in plan.StudentsToRemove)
|
|
{
|
|
<MudListItem T="string">@student.LastNameFirstName (grade @student.Grade)</MudListItem>
|
|
}
|
|
</MudList>
|
|
</MudExpansionPanel>
|
|
<MudExpansionPanel Text="Officers">
|
|
<MudList T="string" Dense="true">
|
|
@foreach (var change in plan.OfficerChanges)
|
|
{
|
|
<MudListItem T="string">
|
|
@change.Role:
|
|
@(change.NewOfficer?.LastNameFirstName ?? "(vacant)")
|
|
@if (change.PreviousOfficer != null)
|
|
{
|
|
<span class="mud-text-secondary"> (was @change.PreviousOfficer.LastNameFirstName)</span>
|
|
}
|
|
</MudListItem>
|
|
}
|
|
</MudList>
|
|
</MudExpansionPanel>
|
|
</MudExpansionPanels>
|
|
|
|
<MudTextField @bind-Value="_applyConfirmYear"
|
|
Label="@($"Type {_targetYear.Trim()} to enable Apply")"
|
|
Variant="Variant.Outlined"
|
|
HelperText="Must exactly match the target competition year above"
|
|
Class="mb-4"
|
|
Style="max-width: 280px;"
|
|
Immediate="true" />
|
|
|
|
<MudButton Variant="Variant.Filled"
|
|
Color="Color.Error"
|
|
StartIcon="@Icons.Material.Filled.Warning"
|
|
Disabled="_isApplying || !CanApply"
|
|
OnClick="ConfirmAndApply">
|
|
@if (_isApplying)
|
|
{
|
|
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
|
<span>Applying...</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Apply rollover</span>
|
|
}
|
|
</MudButton>
|
|
</MudPaper>
|
|
}
|
|
|
|
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mt-2">
|
|
<MudButton Variant="Variant.Text"
|
|
Disabled="Step == 0 || _isApplying"
|
|
OnClick="() => Step--">
|
|
Back
|
|
</MudButton>
|
|
@if (Step < 4)
|
|
{
|
|
<MudButton Variant="Variant.Filled"
|
|
Color="Color.Primary"
|
|
Disabled="!CanGoNext"
|
|
OnClick="GoNext">
|
|
Next
|
|
</MudButton>
|
|
}
|
|
</MudStack>
|
|
}
|
|
</MudContainer>
|
|
|
|
@code {
|
|
private const string UnlockPhrase = "ROLLOVER";
|
|
|
|
private CancellationTokenSource? _cancellationTokenSource;
|
|
private bool _isDisposed;
|
|
private bool _wizardUnlocked;
|
|
private bool _ackDestructive;
|
|
private bool _ackBackupOnlyUndo;
|
|
private string _unlockPhrase = "";
|
|
private string _applyConfirmYear = "";
|
|
private int _step;
|
|
private int Step
|
|
{
|
|
get => _step;
|
|
set
|
|
{
|
|
_step = value;
|
|
if (_step >= 1)
|
|
EnsureReturningDefaults();
|
|
if (_step >= 2)
|
|
PruneOfficerSelections();
|
|
}
|
|
}
|
|
private List<Student>? _students;
|
|
private HashSet<int> _returningIds = [];
|
|
private Dictionary<OfficerRole, int?> _officerSelections = [];
|
|
private readonly OfficerRole[] _officerRoles = Enum.GetValues<OfficerRole>();
|
|
|
|
private string _targetYear = "2027";
|
|
private SchoolLevel? _configuredSchoolLevel;
|
|
private SchoolLevel? _overrideSchoolLevel;
|
|
private int? _graduatingGrade;
|
|
private string _pasteBox = "";
|
|
private List<string> _pasteUnmatched = [];
|
|
private List<string> _pasteAmbiguous = [];
|
|
private bool _clearEventOccurrences = true;
|
|
private bool _isApplying;
|
|
private YearRolloverResult? _result;
|
|
private bool _returningInitialized;
|
|
|
|
private IEnumerable<Student> ReturningStudents =>
|
|
_students?.Where(s => _returningIds.Contains(s.Id)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName)
|
|
?? Enumerable.Empty<Student>();
|
|
|
|
private bool CanUnlockWizard =>
|
|
_ackDestructive &&
|
|
_ackBackupOnlyUndo &&
|
|
string.Equals(_unlockPhrase.Trim(), UnlockPhrase, StringComparison.OrdinalIgnoreCase);
|
|
|
|
private bool CanApply =>
|
|
_wizardUnlocked &&
|
|
_graduatingGrade.HasValue &&
|
|
!string.IsNullOrWhiteSpace(_targetYear) &&
|
|
string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal);
|
|
|
|
private bool CanGoNext => Step switch
|
|
{
|
|
0 => _graduatingGrade.HasValue && !string.IsNullOrWhiteSpace(_targetYear),
|
|
_ => true
|
|
};
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
_cancellationTokenSource = new CancellationTokenSource();
|
|
|
|
var currentYear = Configuration["ChapterSettings:CompetitionYear"] ?? "2026";
|
|
if (int.TryParse(currentYear, out var year))
|
|
_targetYear = (year + 1).ToString();
|
|
else
|
|
_targetYear = currentYear;
|
|
|
|
_configuredSchoolLevel = Configuration.GetSection("ChapterSettings").Get<WebApp.Models.ChapterSettings>()?.SchoolLevel
|
|
?? ParseSchoolLevel(Configuration["ChapterSettings:SchoolLevel"]);
|
|
_graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(_configuredSchoolLevel);
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
try
|
|
{
|
|
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
_students = await Context.Students
|
|
.AsNoTracking()
|
|
.OrderBy(s => s.LastName)
|
|
.ThenBy(s => s.FirstName)
|
|
.ToListAsync(token);
|
|
|
|
foreach (var role in _officerRoles)
|
|
_officerSelections[role] = null;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// disposed
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to load students for year rollover");
|
|
if (!_isDisposed)
|
|
Snackbar.Add($"Failed to load students: {ex.Message}", Severity.Error);
|
|
}
|
|
}
|
|
|
|
private static SchoolLevel? ParseSchoolLevel(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return null;
|
|
return Enum.TryParse<SchoolLevel>(value, ignoreCase: true, out var parsed) ? parsed : null;
|
|
}
|
|
|
|
private void UnlockWizard()
|
|
{
|
|
if (!CanUnlockWizard)
|
|
return;
|
|
|
|
_wizardUnlocked = true;
|
|
Step = 0;
|
|
Snackbar.Add("Year rollover wizard unlocked for this session", Severity.Warning);
|
|
}
|
|
|
|
private void LockWizard()
|
|
{
|
|
_wizardUnlocked = false;
|
|
_ackDestructive = false;
|
|
_ackBackupOnlyUndo = false;
|
|
_unlockPhrase = "";
|
|
_applyConfirmYear = "";
|
|
Step = 0;
|
|
}
|
|
|
|
private void OnOverrideSchoolLevelChanged(SchoolLevel? value)
|
|
{
|
|
_overrideSchoolLevel = value;
|
|
_graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(value);
|
|
_returningInitialized = false;
|
|
}
|
|
|
|
private void EnsureReturningDefaults()
|
|
{
|
|
if (_returningInitialized || _students == null || !_graduatingGrade.HasValue)
|
|
return;
|
|
|
|
_returningIds = _students
|
|
.Where(s => YearTransitionPlanner.SuggestReturning(s, _graduatingGrade.Value))
|
|
.Select(s => s.Id)
|
|
.ToHashSet();
|
|
_returningInitialized = true;
|
|
}
|
|
|
|
private void GoNext()
|
|
{
|
|
Step++;
|
|
}
|
|
|
|
private void SetReturning(int studentId, bool returning)
|
|
{
|
|
if (returning)
|
|
_returningIds.Add(studentId);
|
|
else
|
|
{
|
|
_returningIds.Remove(studentId);
|
|
PruneOfficerSelections();
|
|
}
|
|
}
|
|
|
|
private void ApplyPastedNames()
|
|
{
|
|
if (_students == null)
|
|
return;
|
|
|
|
var lines = _pasteBox.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var result = YearTransitionPlanner.MatchPastedNames(_students, lines);
|
|
foreach (var id in result.MatchedStudentIds)
|
|
_returningIds.Add(id);
|
|
_pasteUnmatched = result.UnmatchedNames.ToList();
|
|
_pasteAmbiguous = result.AmbiguousNames.ToList();
|
|
}
|
|
|
|
private int? GetOfficerSelection(OfficerRole role) =>
|
|
_officerSelections.TryGetValue(role, out var id) ? id : null;
|
|
|
|
private void SetOfficerSelection(OfficerRole role, int? studentId)
|
|
{
|
|
_officerSelections[role] = studentId;
|
|
}
|
|
|
|
private void PruneOfficerSelections()
|
|
{
|
|
foreach (var role in _officerRoles)
|
|
{
|
|
if (_officerSelections.TryGetValue(role, out var id) &&
|
|
id.HasValue &&
|
|
!_returningIds.Contains(id.Value))
|
|
{
|
|
_officerSelections[role] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private YearTransitionPlan BuildPlan()
|
|
{
|
|
return YearTransitionPlanner.Build(new YearTransitionRequest
|
|
{
|
|
Students = _students ?? [],
|
|
ReturningStudentIds = _returningIds,
|
|
OfficerAssignments = _officerSelections,
|
|
GraduatingGrade = _graduatingGrade ?? 8,
|
|
TargetCompetitionYear = _targetYear.Trim(),
|
|
PastedNames = []
|
|
});
|
|
}
|
|
|
|
private async Task ConfirmAndApply()
|
|
{
|
|
if (_isDisposed || !CanApply || !_wizardUnlocked)
|
|
return;
|
|
|
|
if (!string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal))
|
|
{
|
|
Snackbar.Add("Type the target competition year exactly to confirm.", Severity.Warning);
|
|
return;
|
|
}
|
|
|
|
var plan = BuildPlan();
|
|
var message =
|
|
$"This will permanently delete {plan.RemovalCount} student(s), all teams, all event rankings, " +
|
|
$"all meeting history{(_clearEventOccurrences ? ", and all event occurrences" : "")}. " +
|
|
$"An automatic database backup will be created first and is the only undo. Continue?";
|
|
|
|
var confirmed = await DialogService.ShowMessageBox(
|
|
"Confirm year rollover",
|
|
message,
|
|
yesText: "Yes, apply rollover",
|
|
cancelText: "Cancel");
|
|
|
|
if (confirmed != true || _isDisposed || !_wizardUnlocked)
|
|
return;
|
|
|
|
_isApplying = true;
|
|
try
|
|
{
|
|
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
_result = await YearRolloverService.ApplyAsync(new YearRolloverOptions
|
|
{
|
|
Plan = plan,
|
|
ClearEventOccurrences = _clearEventOccurrences
|
|
}, token);
|
|
|
|
if (!_isDisposed)
|
|
Snackbar.Add($"Rollover to {_result.CompetitionYear} complete", Severity.Success);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// disposed
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// connection lost
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Year rollover apply failed");
|
|
if (!_isDisposed)
|
|
Snackbar.Add($"Rollover failed: {ex.Message}", Severity.Error);
|
|
}
|
|
finally
|
|
{
|
|
_isApplying = false;
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
_isDisposed = true;
|
|
_cancellationTokenSource?.Cancel();
|
|
_cancellationTokenSource?.Dispose();
|
|
_cancellationTokenSource = null;
|
|
}
|
|
await ValueTask.CompletedTask;
|
|
}
|
|
}
|