feat: add locked new-year rollover wizard for season transitions

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>
This commit is contained in:
2026-08-14 14:18:18 -04:00
co-authored by Cursor
parent c5c0f95f60
commit afdabd179a
16 changed files with 1677 additions and 50 deletions
+3 -46
View File
@@ -3,10 +3,10 @@
@using WebApp.Authentication
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using System.Text.Json
@using WebApp.Services
@using Core.Models
@inject IWebHostEnvironment Environment
@inject IConfiguration Configuration
@inject IChapterSettingsWriter ChapterSettingsWriter
@rendermode InteractiveServer
@@ -125,19 +125,10 @@
protected override void OnInitialized()
{
// Load from IConfiguration
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
?? new Models.ChapterSettings();
}
private string GetAppSettingsPath()
{
return Path.Combine(
Environment.ContentRootPath,
"Data",
"appsettings.json");
}
private async Task SaveSettings()
{
if (_settings == null) return;
@@ -147,41 +138,7 @@
try
{
var appSettingsPath = GetAppSettingsPath();
// Ensure Data directory exists
var dataDir = Path.GetDirectoryName(appSettingsPath);
if (dataDir != null && !Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
// Read existing appsettings or create new
JsonDocument? existingDoc = null;
Dictionary<string, object?> settings;
if (File.Exists(appSettingsPath))
{
var existingJson = await File.ReadAllTextAsync(appSettingsPath);
existingDoc = JsonDocument.Parse(existingJson);
settings = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
?? new Dictionary<string, object?>();
}
else
{
settings = new Dictionary<string, object?>();
}
// Update ChapterSettings section
settings["ChapterSettings"] = _settings;
// Write back to file
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(settings, options);
await File.WriteAllTextAsync(appSettingsPath, json);
existingDoc?.Dispose();
await ChapterSettingsWriter.WriteAsync(_settings);
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
_statusSeverity = Severity.Success;
}
+651
View File
@@ -0,0 +1,651 @@
@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 &amp; grades</MudStep>
<MudStep Title="Roster">Returning roster</MudStep>
<MudStep Title="Officers">Officers</MudStep>
<MudStep Title="Reset">Season reset</MudStep>
<MudStep Title="Apply">Preview &amp; 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;
}
}
@@ -35,6 +35,7 @@
<AuthorizeView Roles="Administrator">
<MudDivider Class="my-2"/>
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
<MudNavLink Href="/settings/new-year" Icon="@Icons.Material.Filled.EventRepeat">New Year Rollover (locked)</MudNavLink>
<MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink>
</AuthorizeView>
</MudNavMenu>