Files
chapter-organizer/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor
T
poprhythmandCursor 3f50d6e635 feat: add a Tools page printer for note merge and print presets
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>
2026-08-30 15:38:43 -04:00

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.FirstName);
}
@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;
}
}