Files
poprhythm 4dcd9e5aab Add presubmission filter functionality to MeetingSchedule component
This commit introduces a new filter for presubmission teams in the MeetingSchedule component. It adds an AddRemoveFilter for managing teams whose events require presubmission, allowing users to add or remove these teams from the scheduled list. The corresponding methods for adding and removing presubmission teams are implemented, enhancing the component's functionality and improving user experience in managing event teams.
2026-03-10 11:55:06 -04:00

683 lines
28 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="@($"Meeting Scheduler Planner")">
<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 to 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="12" sm="6" lg="4">
<MudPaper Elevation="0" Class="pa-1" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2">Time Slots</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
<MudIconButton Icon="@Icons.Material.Filled.Remove"
OnClick="DecrementTimeSlots"
Disabled="@(_parameters.TimeSlots <= 1)"
Size="Size.Small"
Variant="Variant.Text"
Style="border-radius: var(--mud-default-borderradius) 0 0 var(--mud-default-borderradius);" />
<MudButton Disabled="true"
Variant="Variant.Text"
Style="min-width: 50px; width: 50px; pointer-events: none; border-left: 1px solid var(--mud-palette-lines-default); border-right: 1px solid var(--mud-palette-lines-default); border-radius: 0;">
@_parameters.TimeSlots
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Add"
OnClick="IncrementTimeSlots"
Disabled="@(_parameters.TimeSlots >= 4)"
Size="Size.Small"
Variant="Variant.Text"
Style="border-radius: 0 var(--mud-default-borderradius) var(--mud-default-borderradius) 0;" />
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="High Effort"
OnAdd="AddHighLevelOfEffort"
OnRemove="RemoveHighLevelOfEffort"
AddTooltip="Schedule teams with Level of Effort >= 3"
RemoveTooltip="Remove teams with Level of Effort >= 3" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Regionals"
OnAdd="AddRegionals"
OnRemove="RemoveRegionals"
AddTooltip="Add regional event teams"
RemoveTooltip="Remove regional event teams" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Presubmission"
OnAdd="AddPresubmission"
OnRemove="RemovePresubmission"
AddTooltip="Add teams whose events require presubmission"
RemoveTooltip="Remove teams whose events require presubmission" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Individual"
OnAdd="AddIndividual"
OnRemove="RemoveIndividual"
AddTooltip="Add individual event teams"
RemoveTooltip="Remove individual event teams" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Low Effort"
OnAdd="AddLowLevelOfEffort"
OnRemove="RemoveLowLevelOfEffort"
AddTooltip="Add teams with Level of Effort <= 1"
RemoveTooltip="Remove teams with Level of Effort <= 1" />
</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">
<MudTooltip Text="Load teams from clipboard text by matching team names">
<MudButton Variant="Variant.Outlined" OnClick="LoadTeamsFromClipboard" FullWidth="true" Disabled="@_isLoadingClipboard">Load from Clipboard</MudButton>
</MudTooltip>
</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">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudSpacer />
<MudTooltip Text="Copy to Clipboard">
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
</MudTooltip>
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
OnClick="Solve"
Color="Color.Primary"
Disabled="@_isSolving">
Solve
</MudButton>
</MudStack>
</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 bool _isLoadingClipboard = false;
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 async Task IncrementTimeSlots()
{
if (_parameters.TimeSlots < 4)
{
await OnTimeSlotCountChanged(_parameters.TimeSlots + 1);
}
}
private async Task DecrementTimeSlots()
{
if (_parameters.TimeSlots > 1)
{
await OnTimeSlotCountChanged(_parameters.TimeSlots - 1);
}
}
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
{
_extendedTeams = teams;
StateHasChanged();
}
private void AddRegionals()
{
_scheduledTeams = _scheduledTeams.AddRegionals(_teams);
StateHasChanged();
}
private void AddPresubmission()
{
var presubmissionTeams = _teams.Where(t => t.Event?.Presubmission == true);
_scheduledTeams = _scheduledTeams.Concat(presubmissionTeams).Distinct();
StateHasChanged();
}
private void AddHighLevelOfEffort()
{
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
StateHasChanged();
}
private void RemoveHighLevelOfEffort()
{
var highEffortTeamIds = _teams.Where(t => t.Event.LevelOfEffort >= 3).Select(t => t.Id).ToHashSet();
_scheduledTeams = _scheduledTeams.Where(t => !highEffortTeamIds.Contains(t.Id));
StateHasChanged();
}
private void RemoveRegionals()
{
var regionalTeamIds = _teams.Where(t => t.Event.RegionalEvent).Select(t => t.Id).ToHashSet();
_scheduledTeams = _scheduledTeams.Where(t => !regionalTeamIds.Contains(t.Id));
StateHasChanged();
}
private void RemovePresubmission()
{
var presubmissionTeamIds = _teams
.Where(t => t.Event?.Presubmission == true)
.Select(t => t.Id)
.ToHashSet();
_scheduledTeams = _scheduledTeams.Where(t => !presubmissionTeamIds.Contains(t.Id));
StateHasChanged();
}
private void AddIndividual()
{
var individualTeams = _teams.Where(t => t.Event.EventFormat == EventFormat.Individual);
_scheduledTeams = _scheduledTeams.Concat(individualTeams).Distinct();
StateHasChanged();
}
private void RemoveIndividual()
{
_scheduledTeams = _scheduledTeams.RemoveIndividual();
StateHasChanged();
}
private void AddLowLevelOfEffort()
{
var lowEffortTeams = _teams.Where(t => t.Event.LevelOfEffort <= 1);
_scheduledTeams = _scheduledTeams.Concat(lowEffortTeams).Distinct();
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
}
private async Task LoadTeamsFromClipboard()
{
if (_isLoadingClipboard) return;
try
{
_isLoadingClipboard = true;
StateHasChanged();
var clipboardText = await ClipboardService.ReadTextAsync();
if (string.IsNullOrWhiteSpace(clipboardText))
{
Snackbar.Add("Clipboard is empty", Severity.Warning);
return;
}
var matchedTeams = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _teams);
if (!matchedTeams.Any())
{
Snackbar.Add("No matching teams found in clipboard text", Severity.Info);
return;
}
// Combine with existing scheduled teams, avoiding duplicates
var existingTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
var newTeams = matchedTeams.Where(t => !existingTeamIds.Contains(t.Id));
var updatedTeams = _scheduledTeams.Concat(newTeams).ToList();
OnScheduledTeamsChanged(updatedTeams);
var newCount = newTeams.Count();
var totalCount = matchedTeams.Count();
if (newCount == totalCount)
{
Snackbar.Add($"Selected {totalCount} team(s) from clipboard", Severity.Success);
}
else
{
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
}
}
catch (JSException ex)
{
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading teams from clipboard: {ex.Message}", Severity.Error);
}
finally
{
_isLoadingClipboard = false;
StateHasChanged();
}
}
}