ddb743847d
This commit introduces several new services and models to manage the meeting schedule state within localStorage. The MeetingScheduleState class is created to track scheduled teams, absent students, time slot counts, extended teams, and excluded students. Additionally, the IMeetingScheduleStateService interface and its implementation, MeetingScheduleStateService, are added to handle loading and saving state data. The MeetingScheduleClipboardService and MeetingScheduleDataService are also introduced to facilitate clipboard operations and data loading from the database, respectively. These enhancements improve the overall functionality and user experience of the meeting scheduling feature.
517 lines
20 KiB
Plaintext
517 lines
20 KiB
Plaintext
@page "/meeting-schedule"
|
|
@attribute [Authorize]
|
|
@using System.Text
|
|
@using Core.Calculation
|
|
@using Core.Models
|
|
@using Core.Utility
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using WebApp.Components.Shared.Components
|
|
@using WebApp.Components.Features.MeetingSchedule
|
|
@using WebApp.Models
|
|
@using WebApp.Services
|
|
@inject IConfiguration Configuration
|
|
@inject ClipboardService ClipboardService
|
|
@inject IDialogService DialogService
|
|
@inject ISnackbar Snackbar
|
|
@inject IMeetingScheduleStateService StateService
|
|
@inject IMeetingScheduleDataService DataService
|
|
@inject IMeetingScheduleClipboardService ClipboardFormatService
|
|
@inject LocalStorageService LocalStorage
|
|
|
|
<PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")">
|
|
<ActionButtons>
|
|
<MudTooltip Text="View meeting history">
|
|
<MudButton StartIcon="@Icons.Material.Filled.History"
|
|
Variant="Variant.Outlined"
|
|
Color="Color.Default"
|
|
Href="/meeting-schedule/history">
|
|
View History
|
|
</MudButton>
|
|
</MudTooltip>
|
|
<MudTooltip Text="Save current schedule as meeting history">
|
|
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
|
Variant="Variant.Outlined"
|
|
Color="Color.Primary"
|
|
OnClick="OpenSaveHistoryDialog"
|
|
Disabled="@(!_scheduledTeams.Any())">
|
|
Save as History
|
|
</MudButton>
|
|
</MudTooltip>
|
|
<PageNoteButton PageIdentifier="Meeting Schedule" />
|
|
</ActionButtons>
|
|
</PageHeader>
|
|
|
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
|
<MudGrid>
|
|
<MudItem xs="12" sm="8" lg="9">
|
|
<MudText Typo="Typo.h4">Time Slots</MudText>
|
|
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
|
<MudGrid>
|
|
<MudItem xs="6" sm="3" lg="2">
|
|
<MudNumericField Value="_parameters.TimeSlots"
|
|
ValueChanged="async (int val) => await OnTimeSlotCountChanged(val)"
|
|
Label="Time Slots" Min="1" Max="4">
|
|
</MudNumericField>
|
|
</MudItem>
|
|
<MudFlexBreak/>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
|
|
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
|
|
</MudTooltip>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12">
|
|
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
|
Class="ma-3"
|
|
OnClick="Solve"
|
|
Color="Color.Primary"
|
|
Disabled="@_isSolving">
|
|
Solve
|
|
</MudButton>
|
|
<MudTooltip Text="Copy to Clipboard">
|
|
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
|
</MudTooltip>
|
|
</MudItem>
|
|
|
|
</MudGrid>
|
|
</MudPaper>
|
|
|
|
<MudTable T="TeamScheduleTimeSlot" ServerData="SolveSchedule" @ref="_solutionData">
|
|
<HeaderContent>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd>
|
|
<MudGrid>
|
|
<MudItem xs="12" lg="6">
|
|
<ScheduledTeamsList TimeSlotName="@context.Name"
|
|
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
|
|
Teams="@context.Teams"
|
|
ScheduledTeams="@_scheduledTeams"
|
|
AbsentStudents="@_absentStudents"
|
|
StudentHasOverlaps="@context.StudentHasOverlaps"
|
|
ExcludedStudents="@_excludedStudents"
|
|
OnToggleTeam="@ToggleRequiredTeam"
|
|
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
|
|
</MudItem>
|
|
<MudItem xs="12" lg="6">
|
|
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
|
AbsentStudents="@_absentStudents"
|
|
ScheduledTeams="@_scheduledTeams"
|
|
PossibleAdditions="@_possibleAdditions"
|
|
UnassignedTeams="@_solution.StudentUnassignedTeams"
|
|
OnToggleTeam="@ToggleRequiredTeam" />
|
|
</MudItem>
|
|
</MudGrid>
|
|
</MudTd>
|
|
</RowTemplate>
|
|
</MudTable>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="4" lg="3">
|
|
<MudStack>
|
|
<StudentTextBoxSelector Students="@_students"
|
|
SelectedStudents="_absentStudents"
|
|
SelectedStudentsChanged="OnAbsentStudentsChanged"
|
|
Title="Absent Students"
|
|
Label="Search for absent students"
|
|
ShowFullName="true"/>
|
|
<MudDivider Class="my-4"/>
|
|
<TeamMeetingToggleSelector Teams="@_teams"
|
|
SelectedTeams="_scheduledTeams"
|
|
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
|
ExtendedTeams="_extendedTeams"
|
|
ExtendedTeamsChanged="OnExtendedTeamsChanged"
|
|
Title="Scheduled Teams"
|
|
ShowEventAttributes="false" />
|
|
</MudStack>
|
|
</MudItem>
|
|
|
|
</MudGrid>
|
|
</MudPaper>
|
|
|
|
@code {
|
|
private Team[] _teams = null!;
|
|
private Student[] _students = null!;
|
|
MudTable<TeamScheduleTimeSlot> _solutionData = null!;
|
|
private TeamSchedulerSolution _solution = null!;
|
|
private TeamSchedulerOptions _parameters = null!;
|
|
bool _isSolving;
|
|
private IEnumerable<Team> _scheduledTeams = [];
|
|
private IEnumerable<Student> _absentStudents = [];
|
|
private IEnumerable<Team> _possibleAdditions = [];
|
|
private IEnumerable<Team> _extendedTeams = [];
|
|
// Key: (teamId, timeSlotIndex, studentId) - Value: true if excluded
|
|
private Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> _excludedStudents = new();
|
|
// Track last saved state for dirty/clean comparison
|
|
private MeetingScheduleState? _lastSavedState;
|
|
|
|
private void OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
|
{
|
|
_scheduledTeams = teams;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
|
|
{
|
|
_absentStudents = students;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task OnTimeSlotCountChanged(int timeSlots)
|
|
{
|
|
_parameters.TimeSlots = timeSlots;
|
|
StateHasChanged();
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
|
|
{
|
|
_extendedTeams = teams;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void AddRegionals()
|
|
{
|
|
_scheduledTeams = _scheduledTeams.AddRegionals(_teams);
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void AddHighLevelOfEffort()
|
|
{
|
|
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void RemoveIndividual()
|
|
{
|
|
_scheduledTeams = _scheduledTeams.RemoveIndividual();
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void RemoveLowLevelOfEffort()
|
|
{
|
|
_scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort();
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void Invert()
|
|
{
|
|
_scheduledTeams = _scheduledTeams.Invert(_teams);
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void Reset()
|
|
{
|
|
_scheduledTeams = [];
|
|
_extendedTeams = [];
|
|
_excludedStudents.Clear();
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void ToggleRequiredTeam(Team unassignedTeam)
|
|
{
|
|
// Find the matching team from _teams to ensure reference equality with MudToggleGroup
|
|
var matchingTeam = _teams.FirstOrDefault(t => t.Id == unassignedTeam.Id);
|
|
if (matchingTeam == null) return;
|
|
|
|
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
|
|
IEnumerable<Team> newScheduledTeams;
|
|
|
|
if (scheduledTeamIds.Contains(matchingTeam.Id))
|
|
newScheduledTeams = _scheduledTeams.Where(t => t.Id != matchingTeam.Id);
|
|
else
|
|
{
|
|
newScheduledTeams = _scheduledTeams.Concat([matchingTeam]);
|
|
}
|
|
|
|
// Update state and notify component to re-render
|
|
OnScheduledTeamsChanged(newScheduledTeams);
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
_parameters =
|
|
new TeamSchedulerOptions(
|
|
2,
|
|
mustIncludeEvents:
|
|
[
|
|
// "Medical Technology", "Electrical Applications" , "RegionalTeam",
|
|
// ,"Dragster", "Flight"
|
|
],
|
|
extended:
|
|
[
|
|
// "Invention", "Construction Challenge", "Mechanical", "Mass", "Micro"
|
|
//"STEM"
|
|
//"Community", "Vlogging"// "Microcontroller"
|
|
],
|
|
omittedEvents:
|
|
[
|
|
// "Vlogging", "Junior", "Community Service Video", "Digital Photography",
|
|
// "STEM"
|
|
|
|
//"Leadership",// "Electrical", //"Construction"
|
|
// "Forensic",
|
|
//"CAD"
|
|
//"I&I Team 1", "I&I Team 2"//, "Website Design",
|
|
],
|
|
absentStudents:
|
|
[
|
|
]
|
|
);
|
|
|
|
_teams = await DataService.LoadTeamsAsync();
|
|
_students = await DataService.LoadStudentsAsync();
|
|
|
|
// Load saved selections from localStorage
|
|
_scheduledTeams = await StateService.LoadScheduledTeamsAsync(_teams);
|
|
_absentStudents = await StateService.LoadAbsentStudentsAsync(_students);
|
|
_parameters.TimeSlots = await StateService.LoadTimeSlotCountAsync(2);
|
|
_extendedTeams = await StateService.LoadExtendedTeamsAsync(_teams);
|
|
_excludedStudents = await StateService.LoadExcludedStudentsAsync();
|
|
|
|
// Initialize last saved state from loaded values
|
|
_lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students);
|
|
if (_lastSavedState == null)
|
|
{
|
|
// If no saved state exists, create initial state from current values
|
|
_lastSavedState = MeetingScheduleState.FromCurrent(
|
|
_scheduledTeams,
|
|
_absentStudents,
|
|
_parameters.TimeSlots,
|
|
_extendedTeams,
|
|
_excludedStudents);
|
|
}
|
|
}
|
|
|
|
|
|
private int GetTimeSlotIndex(string timeSlotName)
|
|
{
|
|
if (_solution?.TimeSlots == null)
|
|
return 0; // Default to first slot if solution not available
|
|
|
|
for (int i = 0; i < _solution.TimeSlots.Length; i++)
|
|
{
|
|
if (_solution.TimeSlots[i].Name == timeSlotName)
|
|
return i;
|
|
}
|
|
return 0; // Default to first slot if not found (shouldn't happen in normal flow)
|
|
}
|
|
|
|
private void OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key)
|
|
{
|
|
if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded)
|
|
{
|
|
_excludedStudents.Remove(key);
|
|
}
|
|
else
|
|
{
|
|
_excludedStudents[key] = true;
|
|
}
|
|
StateHasChanged();
|
|
}
|
|
|
|
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
|
|
{
|
|
var key = (teamId, timeSlotIndex, studentId);
|
|
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
|
}
|
|
|
|
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
|
|
{
|
|
var absentStudentIds = _absentStudents.Select(s => s.Id).ToHashSet();
|
|
return OverlapCalculationHelper.GetTeamsWithoutExcludedStudents(teams, timeSlotIndex, _excludedStudents, absentStudentIds);
|
|
}
|
|
|
|
private bool IsDirty()
|
|
{
|
|
if (_lastSavedState == null)
|
|
return true;
|
|
|
|
var currentState = MeetingScheduleState.FromCurrent(
|
|
_scheduledTeams,
|
|
_absentStudents,
|
|
_parameters.TimeSlots,
|
|
_extendedTeams,
|
|
_excludedStudents);
|
|
|
|
return !currentState.Equals(_lastSavedState);
|
|
}
|
|
|
|
private async Task<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
|
|
{
|
|
_isSolving = true;
|
|
|
|
// Check if there are any teams to schedule
|
|
if (!_scheduledTeams.Any())
|
|
{
|
|
_isSolving = false;
|
|
_solution = new TeamSchedulerSolution([], [], "No teams selected");
|
|
return new TableData<TeamScheduleTimeSlot> { Items = [] };
|
|
}
|
|
|
|
// Filter out absent students
|
|
var availableStudents = _students.Where(s => !_absentStudents.Contains(s)).ToArray();
|
|
|
|
// Check if there are any available students
|
|
if (availableStudents.Length == 0)
|
|
{
|
|
_isSolving = false;
|
|
_solution = new TeamSchedulerSolution([], [], "No available students");
|
|
return new TableData<TeamScheduleTimeSlot> { Items = [] };
|
|
}
|
|
|
|
// Update parameters with absent student names
|
|
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
|
|
|
|
// Update parameters with extended team event names
|
|
_parameters.ExtendedTeams = _extendedTeams
|
|
.Where(t => t.Event != null)
|
|
.Select(t => t.Event!.Name)
|
|
.ToArray();
|
|
|
|
// Create PartialTeam instances for teams with excluded students
|
|
var teamsForScheduling = PartialTeam.CreatePartialTeamsFromExclusions(_scheduledTeams, _excludedStudents);
|
|
|
|
var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
|
|
_solution = teamScheduler.Solve();
|
|
|
|
// Restore full teams (with all students) in the solution so excluded students still appear (dimmed)
|
|
// Create a mapping from PartialTeam to original Team
|
|
var teamMapping = _scheduledTeams.ToDictionary(t => t.Id, t => t);
|
|
|
|
for (int slotIndex = 0; slotIndex < _solution.TimeSlots.Length; slotIndex++)
|
|
{
|
|
var slot = _solution.TimeSlots[slotIndex];
|
|
if (slot.Teams == null)
|
|
continue;
|
|
|
|
var restoredTeams = slot.Teams.Select(team =>
|
|
{
|
|
// If this is a PartialTeam or we have the original, restore it
|
|
if (teamMapping.TryGetValue(team.Id, out var originalTeam))
|
|
{
|
|
return originalTeam;
|
|
}
|
|
return team;
|
|
}).ToArray();
|
|
|
|
slot.Teams = restoredTeams;
|
|
|
|
// Recalculate overlaps and unscheduled students with full teams
|
|
// Filter out excluded students when calculating overlaps and unscheduled students
|
|
var teamsForOverlapCalculation = GetTeamsWithoutExcludedStudents(slot.Teams, slotIndex);
|
|
slot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(teamsForOverlapCalculation);
|
|
// Use teams without excluded students so students excluded from all teams appear as unscheduled
|
|
slot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(teamsForOverlapCalculation, availableStudents);
|
|
}
|
|
|
|
// Post-process: extend teams to next consecutive time slot
|
|
if (_extendedTeams.Any())
|
|
{
|
|
TeamSchedulerPostProcessor.ExtendTeamsInSolution(
|
|
_solution,
|
|
_extendedTeams,
|
|
availableStudents,
|
|
GetTeamsWithoutExcludedStudents);
|
|
}
|
|
|
|
// Try recommendation strategies in priority order
|
|
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
|
UnassignedScheduleStrategy[] strategies =
|
|
[
|
|
UnassignedScheduleStrategy.LevelOfEffort,
|
|
UnassignedScheduleStrategy.BiggestGroup,
|
|
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
|
UnassignedScheduleStrategy.IndividualEvents
|
|
];
|
|
|
|
_possibleAdditions = strategies
|
|
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
|
.FirstOrDefault(result => result.Any()) ?? [];
|
|
|
|
// Save state to localStorage after solving completes successfully
|
|
var currentState = MeetingScheduleState.FromCurrent(
|
|
_scheduledTeams,
|
|
_absentStudents,
|
|
_parameters.TimeSlots,
|
|
_extendedTeams,
|
|
_excludedStudents);
|
|
await currentState.SaveToLocalStorage(LocalStorage);
|
|
_lastSavedState = currentState;
|
|
|
|
// Also save via state service for consistency
|
|
await StateService.SaveScheduledTeamsAsync(_scheduledTeams);
|
|
await StateService.SaveAbsentStudentsAsync(_absentStudents);
|
|
await StateService.SaveTimeSlotCountAsync(_parameters.TimeSlots);
|
|
await StateService.SaveExtendedTeamsAsync(_extendedTeams);
|
|
await StateService.SaveExcludedStudentsAsync(_excludedStudents);
|
|
|
|
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
|
|
|
_isSolving = false;
|
|
return new TableData<TeamScheduleTimeSlot> { Items = _solution.TimeSlots };
|
|
}
|
|
|
|
private void Solve()
|
|
{
|
|
_solutionData.ReloadServerData();
|
|
}
|
|
|
|
|
|
async Task CopyToClipboard()
|
|
{
|
|
try
|
|
{
|
|
var text = ClipboardFormatService.FormatScheduleForClipboard(
|
|
_solution,
|
|
_teams,
|
|
_absentStudents,
|
|
_excludedStudents,
|
|
GetTimeSlotIndex);
|
|
await ClipboardService.WriteTextAsync(text);
|
|
}
|
|
catch
|
|
{
|
|
Console.WriteLine("Cannot write text to clipboard");
|
|
}
|
|
}
|
|
|
|
private async Task OpenSaveHistoryDialog()
|
|
{
|
|
var parameters = new DialogParameters
|
|
{
|
|
["ScheduledTeams"] = _scheduledTeams,
|
|
["AbsentStudents"] = _absentStudents,
|
|
["AllTeams"] = _teams,
|
|
["AllStudents"] = _students
|
|
};
|
|
|
|
var options = new DialogOptions
|
|
{
|
|
MaxWidth = MaxWidth.Medium,
|
|
FullWidth = true,
|
|
CloseButton = true
|
|
};
|
|
|
|
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Save Meeting History", parameters, options);
|
|
var result = await dialog.Result;
|
|
|
|
// Note: Success message is already shown in the dialog, no need to show another here
|
|
}
|
|
|
|
|
|
|
|
} |