Chapter officers can merge a markdown note onto filtered students, teams, or events and save the recipe. Extra student-note columns are additional fields (any ## … fields heading) so they work as print tokens whether imported or typed by hand. Co-authored-by: Cursor <cursoragent@cursor.com>
683 lines
28 KiB
Plaintext
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)
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
|
|
|
|
} |