Keep legal first and last names for formal lists; show DisplayFirstName on teams, calendars, and import matching so two Josiahs can be told apart. Co-authored-by: Cursor <cursoragent@cursor.com>
502 lines
18 KiB
Plaintext
502 lines
18 KiB
Plaintext
@namespace WebApp.Components.Features.MeetingSchedule
|
|
@using Core.Services
|
|
@using WebApp.Models
|
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
|
@inject INotesService NotesService
|
|
@inject INoteNamingService NoteNamingService
|
|
@inject ISnackbar Snackbar
|
|
@inject IDialogService DialogService
|
|
@inject IMeetingScheduleDataService DataService
|
|
@inject IMeetingScheduleStateService StateService
|
|
@inject NavigationManager NavigationManager
|
|
@implements IAsyncDisposable
|
|
|
|
<MudDialog>
|
|
<DialogContent>
|
|
@if (_isLoading)
|
|
{
|
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
|
}
|
|
else if (_meetingHistory == null)
|
|
{
|
|
<MudAlert Severity="Severity.Error">Meeting history not found.</MudAlert>
|
|
}
|
|
else
|
|
{
|
|
<MudStack Spacing="3">
|
|
@* Navigation Header *@
|
|
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Spacing="2">
|
|
<MudTooltip Text="@(_previousMeetingHistory != null ? $"Previous: {_previousMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No previous meeting")">
|
|
<span>
|
|
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
|
OnClick="NavigateToPrevious"
|
|
Disabled="@(_previousMeetingHistory == null || IsActionDisabled)"
|
|
Color="Color.Primary"
|
|
Size="Size.Medium" />
|
|
</span>
|
|
</MudTooltip>
|
|
<MudText Typo="Typo.h6" Class="flex-grow-1" Style="text-align: center;">@_meetingHistory.MeetingDate.ToString("MM/dd/yyyy")</MudText>
|
|
<MudTooltip Text="@(_nextMeetingHistory != null ? $"Next: {_nextMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No next meeting")">
|
|
<span>
|
|
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
|
OnClick="NavigateToNext"
|
|
Disabled="@(_nextMeetingHistory == null || IsActionDisabled)"
|
|
Color="Color.Primary"
|
|
Size="Size.Medium" />
|
|
</span>
|
|
</MudTooltip>
|
|
</MudStack>
|
|
|
|
<MudGrid>
|
|
<MudItem xs="12" md="6">
|
|
<MudText Typo="Typo.subtitle1">Teams That Met (@_meetingHistory.Teams.Count)</MudText>
|
|
<MudPaper Elevation="1" Class="pa-2">
|
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
|
@foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
|
{
|
|
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="@AppIcons.TeamChipVariant()" Class="mx-1 my-1">@team.ToString()</MudChip>
|
|
}
|
|
</MudStack>
|
|
</MudPaper>
|
|
</MudItem>
|
|
<MudItem xs="12" md="6">
|
|
<MudText Typo="Typo.subtitle1">Students (@GetAllStudentsFromTeams().Count)</MudText>
|
|
<MudPaper Elevation="1" Class="pa-2">
|
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
|
@{
|
|
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
|
|
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.DisplayFirstName);
|
|
}
|
|
@foreach (var student in allStudents)
|
|
{
|
|
var isPresent = presentStudentIds.Contains(student.Id);
|
|
<MudChip T="string"
|
|
Size="Size.Small"
|
|
Color="Color.Default"
|
|
Variant="@AppIcons.StudentChipVariant()"
|
|
Class="mx-1 my-1"
|
|
Style="@(!isPresent ? "opacity: 0.5;" : "")">
|
|
@student.FirstNameLastName
|
|
</MudChip>
|
|
}
|
|
</MudStack>
|
|
</MudPaper>
|
|
</MudItem>
|
|
</MudGrid>
|
|
|
|
@if (_meetingNote != null)
|
|
{
|
|
<MudDivider />
|
|
<MudText Typo="Typo.subtitle1">Meeting Notes</MudText>
|
|
<MudPaper Elevation="1" Class="pa-3">
|
|
<MudText Typo="Typo.body2"><strong>@_meetingNote.Title</strong></MudText>
|
|
@if (!string.IsNullOrWhiteSpace(_meetingNote.Content))
|
|
{
|
|
<MudText Typo="Typo.body2" Class="mt-2">
|
|
@((MarkupString)MarkdownHelper.ToHtml(_meetingNote.Content))
|
|
</MudText>
|
|
}
|
|
<MudButton Variant="Variant.Text"
|
|
Size="Size.Small"
|
|
Color="Color.Primary"
|
|
StartIcon="@Icons.Material.Filled.Edit"
|
|
OnClick="ViewNote"
|
|
Class="mt-2">
|
|
Edit Note
|
|
</MudButton>
|
|
</MudPaper>
|
|
}
|
|
</MudStack>
|
|
}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
@if (_meetingHistory != null)
|
|
{
|
|
<MudButton Variant="Variant.Text"
|
|
Color="Color.Error"
|
|
OnClick="ConfirmDelete"
|
|
Disabled="@IsActionDisabled">
|
|
Delete
|
|
</MudButton>
|
|
<MudSpacer />
|
|
<MudButton Variant="Variant.Text"
|
|
Color="Color.Secondary"
|
|
OnClick="LoadIntoPlanner"
|
|
Disabled="@IsActionDisabled"
|
|
StartIcon="@Icons.Material.Filled.Upload">
|
|
Load into Planner
|
|
</MudButton>
|
|
<MudButton Variant="Variant.Text"
|
|
Color="Color.Primary"
|
|
OnClick="OpenEditDialog"
|
|
Disabled="@IsActionDisabled"
|
|
StartIcon="@Icons.Material.Filled.Edit">
|
|
Edit
|
|
</MudButton>
|
|
<MudButton OnClick="Close" Disabled="@IsActionDisabled">Close</MudButton>
|
|
}
|
|
else
|
|
{
|
|
<MudSpacer />
|
|
<MudButton OnClick="Close">Close</MudButton>
|
|
}
|
|
</DialogActions>
|
|
</MudDialog>
|
|
|
|
@code {
|
|
[CascadingParameter]
|
|
IMudDialogInstance MudDialog { get; set; } = null!;
|
|
|
|
[Parameter]
|
|
public int MeetingHistoryId { get; set; }
|
|
|
|
private TeamMeetingHistory? _meetingHistory;
|
|
private Note? _meetingNote;
|
|
private bool _isLoading = true;
|
|
private bool _isDeleting = false;
|
|
private Team[] _allTeams = [];
|
|
private Student[] _allStudents = [];
|
|
private CancellationTokenSource? _cancellationTokenSource;
|
|
private bool _isDisposed = false;
|
|
private bool IsActionDisabled => _isLoading || _isDeleting;
|
|
private TeamMeetingHistory? _previousMeetingHistory;
|
|
private TeamMeetingHistory? _nextMeetingHistory;
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
_cancellationTokenSource = new CancellationTokenSource();
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
// Load all teams and students for the edit dialog
|
|
_allTeams = await DataService.LoadTeamsAsync();
|
|
_allStudents = await DataService.LoadStudentsAsync();
|
|
await LoadMeetingHistory();
|
|
}
|
|
|
|
private async Task LoadMeetingHistory()
|
|
{
|
|
if (_isDisposed) return;
|
|
|
|
try
|
|
{
|
|
_meetingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId);
|
|
|
|
// Load note by title if meeting history exists
|
|
if (_meetingHistory != null)
|
|
{
|
|
_meetingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(_meetingHistory.MeetingDate);
|
|
|
|
// Load all meeting histories to find previous/next
|
|
await LoadNavigationMeetings();
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// Component was disposed, ignore
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// JS connection lost, ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
_isLoading = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task LoadNavigationMeetings()
|
|
{
|
|
if (_isDisposed || _meetingHistory == null) return;
|
|
|
|
try
|
|
{
|
|
// Get all meeting histories ordered by date
|
|
var allMeetings = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync())
|
|
.OrderBy(m => m.MeetingDate)
|
|
.ThenBy(m => m.Id)
|
|
.ToList();
|
|
|
|
var currentIndex = allMeetings.FindIndex(m => m.Id == _meetingHistory.Id);
|
|
|
|
if (currentIndex >= 0)
|
|
{
|
|
_previousMeetingHistory = currentIndex > 0 ? allMeetings[currentIndex - 1] : null;
|
|
_nextMeetingHistory = currentIndex < allMeetings.Count - 1 ? allMeetings[currentIndex + 1] : null;
|
|
}
|
|
else
|
|
{
|
|
_previousMeetingHistory = null;
|
|
_nextMeetingHistory = null;
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// Component was disposed, ignore
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// JS connection lost, ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
// Log error but don't show snackbar - navigation is not critical
|
|
System.Diagnostics.Debug.WriteLine($"Error loading navigation meetings: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task NavigateToPrevious()
|
|
{
|
|
if (_previousMeetingHistory == null || _isDisposed) return;
|
|
|
|
// Close current dialog and open new one with previous meeting ID
|
|
var parameters = new DialogParameters
|
|
{
|
|
["MeetingHistoryId"] = _previousMeetingHistory.Id
|
|
};
|
|
|
|
var options = new DialogOptions
|
|
{
|
|
MaxWidth = MaxWidth.Medium,
|
|
FullWidth = true,
|
|
CloseButton = true
|
|
};
|
|
|
|
MudDialog.Close();
|
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
|
|
}
|
|
|
|
private async Task NavigateToNext()
|
|
{
|
|
if (_nextMeetingHistory == null || _isDisposed) return;
|
|
|
|
// Close current dialog and open new one with next meeting ID
|
|
var parameters = new DialogParameters
|
|
{
|
|
["MeetingHistoryId"] = _nextMeetingHistory.Id
|
|
};
|
|
|
|
var options = new DialogOptions
|
|
{
|
|
MaxWidth = MaxWidth.Medium,
|
|
FullWidth = true,
|
|
CloseButton = true
|
|
};
|
|
|
|
MudDialog.Close();
|
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
|
|
}
|
|
|
|
private async Task ConfirmDelete()
|
|
{
|
|
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
|
|
|
|
var result = await DialogService.ShowMessageBox(
|
|
"Confirm Delete",
|
|
$"Are you sure you want to delete the meeting history for {_meetingHistory.MeetingDate:MM/dd/yyyy}? This action cannot be undone.",
|
|
yesText: "Delete",
|
|
cancelText: "Cancel");
|
|
|
|
if (result == true)
|
|
{
|
|
await DeleteMeetingHistory();
|
|
}
|
|
}
|
|
|
|
private async Task DeleteMeetingHistory()
|
|
{
|
|
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
|
|
|
|
try
|
|
{
|
|
_isDeleting = true;
|
|
StateHasChanged();
|
|
|
|
await TeamMeetingHistoryService.DeleteMeetingHistoryAsync(MeetingHistoryId);
|
|
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add("Meeting history deleted successfully", Severity.Success);
|
|
MudDialog.Close(DialogResult.Ok(true));
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// Component was disposed, ignore
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// JS connection lost, ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add($"Error deleting meeting history: {ex.Message}", Severity.Error);
|
|
_isDeleting = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ViewNote()
|
|
{
|
|
if (_meetingNote == null) return;
|
|
|
|
var parameters = new DialogParameters
|
|
{
|
|
["NoteId"] = _meetingNote.Id
|
|
};
|
|
|
|
var options = new DialogOptions
|
|
{
|
|
MaxWidth = MaxWidth.Medium,
|
|
FullWidth = true,
|
|
CloseButton = true
|
|
};
|
|
|
|
var dialog = await DialogService.ShowAsync<NoteViewDialog>("Meeting Notes", parameters, options);
|
|
await dialog.Result;
|
|
|
|
// Refresh meeting history to get updated note
|
|
await LoadMeetingHistory();
|
|
}
|
|
|
|
private async Task OpenEditDialog()
|
|
{
|
|
if (_meetingHistory == null || _isDisposed) return;
|
|
|
|
var parameters = new DialogParameters
|
|
{
|
|
["MeetingHistoryId"] = _meetingHistory.Id,
|
|
["AllTeams"] = _allTeams,
|
|
["AllStudents"] = _allStudents,
|
|
["ScheduledTeams"] = new List<Team>(), // Not used when editing
|
|
["AbsentStudents"] = new List<Student>() // Not used when editing
|
|
};
|
|
|
|
var options = new DialogOptions
|
|
{
|
|
MaxWidth = MaxWidth.Medium,
|
|
FullWidth = true,
|
|
CloseButton = true
|
|
};
|
|
|
|
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Edit Meeting History", parameters, options);
|
|
var result = await dialog.Result;
|
|
|
|
// Refresh meeting history if dialog was saved
|
|
if (result is { Canceled: false } && !_isDisposed)
|
|
{
|
|
await LoadMeetingHistory();
|
|
}
|
|
}
|
|
|
|
private List<Student> GetAllStudentsFromTeams()
|
|
{
|
|
if (_meetingHistory == null)
|
|
return [];
|
|
|
|
var allStudents = new List<Student>();
|
|
var studentIds = new HashSet<int>();
|
|
|
|
foreach (var team in _meetingHistory.Teams)
|
|
{
|
|
foreach (var student in team.Students)
|
|
{
|
|
if (!studentIds.Contains(student.Id))
|
|
{
|
|
studentIds.Add(student.Id);
|
|
allStudents.Add(student);
|
|
}
|
|
}
|
|
}
|
|
|
|
return allStudents;
|
|
}
|
|
|
|
private async Task LoadIntoPlanner()
|
|
{
|
|
if (_isDisposed || _meetingHistory == null) return;
|
|
|
|
try
|
|
{
|
|
// Get all teams and students from database
|
|
var allTeams = await DataService.LoadTeamsAsync();
|
|
var allStudents = await DataService.LoadStudentsAsync();
|
|
|
|
// Match teams from history to all teams by ID for reference equality
|
|
var historyTeamIds = _meetingHistory.Teams.Select(t => t.Id).ToHashSet();
|
|
var scheduledTeams = allTeams.Where(t => historyTeamIds.Contains(t.Id));
|
|
|
|
// Calculate absent students (all students not in the meeting history's student list)
|
|
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
|
|
var absentStudents = allStudents.Where(s => !presentStudentIds.Contains(s.Id));
|
|
|
|
// Save state to localStorage
|
|
await StateService.SaveScheduledTeamsAsync(scheduledTeams);
|
|
await StateService.SaveAbsentStudentsAsync(absentStudents);
|
|
// Clear extended teams and excluded students when loading from history
|
|
await StateService.SaveExtendedTeamsAsync([]);
|
|
await StateService.SaveExcludedStudentsAsync(new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>());
|
|
|
|
// Close dialog and navigate to planner
|
|
MudDialog.Close();
|
|
NavigationManager.NavigateTo("/meeting-schedule");
|
|
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add($"Loaded meeting from {_meetingHistory.MeetingDate:MM/dd/yyyy} into planner", Severity.Success);
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
// Component was disposed, ignore
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// JS connection lost, ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
Snackbar.Add($"Error loading meeting into planner: {ex.Message}", Severity.Error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Close()
|
|
{
|
|
if (_isDisposed) return;
|
|
MudDialog.Close();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (!_isDisposed)
|
|
{
|
|
_isDisposed = true;
|
|
_cancellationTokenSource?.Cancel();
|
|
_cancellationTokenSource?.Dispose();
|
|
_cancellationTokenSource = null;
|
|
}
|
|
await ValueTask.CompletedTask;
|
|
}
|
|
}
|