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>
398 lines
15 KiB
Plaintext
398 lines
15 KiB
Plaintext
@page "/meeting-schedule/history"
|
||
@attribute [Authorize]
|
||
@using Core.Entities
|
||
@using Microsoft.EntityFrameworkCore
|
||
@using WebApp.Components.Shared.Components
|
||
@using WebApp.Services
|
||
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||
@inject AppDbContext Context
|
||
@inject IDialogService DialogService
|
||
@inject ISnackbar Snackbar
|
||
@inject IConfiguration Configuration
|
||
@inject IMeetingScheduleDataService DataService
|
||
@inject IMeetingScheduleStateService StateService
|
||
@inject NavigationManager NavigationManager
|
||
@implements IAsyncDisposable
|
||
|
||
<PageHeader Title="Team Meeting Schedule History">
|
||
<ActionButtons>
|
||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||
Variant="Variant.Outlined"
|
||
Color="Color.Primary"
|
||
Href="/meeting-schedule">
|
||
Back to Schedule
|
||
</MudButton>
|
||
</ActionButtons>
|
||
</PageHeader>
|
||
|
||
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||
@if (_isLoading)
|
||
{
|
||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||
}
|
||
else
|
||
{
|
||
<MudStack Spacing="3">
|
||
@if (_meetingHistories.Any())
|
||
{
|
||
<MudPaper Elevation="1" Class="pa-3" Style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||
<table class="history-grid-table" style="min-width: max-content; width: 100%; border-collapse: collapse;">
|
||
<colgroup>
|
||
<col style="width: 72px;" />
|
||
<col style="width: 40px;" />
|
||
@for (var c = 0; c < _allTeams.Count; c++)
|
||
{
|
||
<col style="width: 28px;" />
|
||
}
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th class="history-cell history-cell-date">
|
||
<span class="mud-typography mud-typography-caption">Date</span>
|
||
</th>
|
||
<th class="history-cell history-cell-actions">
|
||
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">Actions</span>
|
||
</th>
|
||
@{
|
||
var teamIndex = 0;
|
||
}
|
||
@foreach (var team in _allTeams)
|
||
{
|
||
var isEven = teamIndex % 2 == 0;
|
||
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
|
||
<th class="@colClass">
|
||
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">@team.ToString()</span>
|
||
</th>
|
||
teamIndex++;
|
||
}
|
||
</tr>
|
||
<tr>
|
||
<th class="history-cell history-cell-date history-cell-times-met">Times Met</th>
|
||
<th class="history-cell history-cell-actions "></th>
|
||
@{
|
||
var summaryTeamIndex = 0;
|
||
}
|
||
@foreach (var team in _allTeams)
|
||
{
|
||
var timesMet = GetTimesMetForTeam(team);
|
||
var isEven = summaryTeamIndex % 2 == 0;
|
||
var colClass = isEven ? "history-cell history-cell-col-even history-cell-times-met" : "history-cell history-cell-col-odd history-cell-times-met";
|
||
<th class="@colClass">@timesMet</th>
|
||
summaryTeamIndex++;
|
||
}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@{
|
||
var rowIndex = 0;
|
||
}
|
||
@foreach (var history in _meetingHistories)
|
||
{
|
||
var rowTeamIndex = 0;
|
||
var rowClass = rowIndex % 2 == 0 ? "history-row-even" : "history-row-odd";
|
||
<tr class="@rowClass">
|
||
<td class="history-cell history-cell-date ">
|
||
<MudButton Variant="Variant.Text"
|
||
Color="Color.Primary"
|
||
Size="Size.Small"
|
||
OnClick="@(() => ViewMeetingDetails(history))"
|
||
Style="text-transform: none; padding: 0 2px; min-width: unset;">
|
||
@history.MeetingDate.ToString("MM/dd/yy")
|
||
</MudButton>
|
||
</td>
|
||
<td class="history-cell history-cell-actions">
|
||
<MudTooltip Text="Load into Planner">
|
||
<MudIconButton Icon="@Icons.Material.Filled.Upload"
|
||
Size="Size.Small"
|
||
Color="Color.Secondary"
|
||
OnClick="@(() => LoadMeetingIntoPlanner(history))"
|
||
Variant="Variant.Text"
|
||
Style="padding: 2px;" />
|
||
</MudTooltip>
|
||
</td>
|
||
@foreach (var team in _allTeams)
|
||
{
|
||
var met = TeamMetOnDate(history, team);
|
||
var isEven = rowTeamIndex % 2 == 0;
|
||
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
|
||
<td class="@colClass">
|
||
@if (met)
|
||
{
|
||
<span class="mud-typography mud-typography-body1" style="font-weight: bold;">×</span>
|
||
}
|
||
</td>
|
||
rowTeamIndex++;
|
||
}
|
||
</tr>
|
||
rowIndex++;
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</MudPaper>
|
||
}
|
||
else
|
||
{
|
||
<MudAlert Severity="Severity.Info">No meeting history found. Save a meeting schedule to get started.</MudAlert>
|
||
}
|
||
</MudStack>
|
||
}
|
||
</MudPaper>
|
||
|
||
<style>
|
||
.history-grid-table {
|
||
--history-border: 1px solid var(--mud-palette-divider);
|
||
--history-shade: var(--mud-palette-background-grey);
|
||
}
|
||
.history-grid-table th,
|
||
.history-grid-table td {
|
||
padding: 2px 4px;
|
||
border-right: var(--history-border);
|
||
border-bottom: var(--history-border);
|
||
vertical-align: middle;
|
||
}
|
||
.history-grid-table thead th {
|
||
border-top: var(--history-border);
|
||
background-color: var(--mud-palette-surface);
|
||
}
|
||
.history-grid-table thead .history-cell-col-odd {
|
||
background-color: var(--mud-palette-surface);
|
||
}
|
||
.history-grid-table thead th:first-child,
|
||
.history-grid-table tbody td:first-child {
|
||
border-left: var(--history-border);
|
||
}
|
||
.history-grid-table .history-cell-date {
|
||
padding: 2px 4px;
|
||
text-align: left;
|
||
min-width: 72px;
|
||
}
|
||
.history-grid-table .history-cell-actions {
|
||
padding: 2px;
|
||
text-align: center;
|
||
min-width: 40px;
|
||
}
|
||
.history-grid-table thead .history-cell-col-even {
|
||
background-color: var(--history-shade);
|
||
}
|
||
.history-grid-table .history-cell-times-met {
|
||
font-weight: bold;
|
||
background-color: var(--history-shade);
|
||
}
|
||
.history-grid-table tbody .history-row-odd td {
|
||
background-color: var(--history-shade);
|
||
}
|
||
.history-grid-table tbody .history-row-even td.history-cell-col-even,
|
||
.history-grid-table tbody .history-row-odd td.history-cell-col-even {
|
||
background-color: var(--history-shade);
|
||
}
|
||
.history-grid-table tbody .history-row-odd td.history-cell-col-odd {
|
||
background-color: var(--history-shade);
|
||
}
|
||
</style>
|
||
|
||
@code {
|
||
private List<TeamMeetingHistory> _meetingHistories = [];
|
||
private List<Team> _allTeams = [];
|
||
private Dictionary<int, int> _timesMetDict = new();
|
||
private bool _isLoading = true;
|
||
private CancellationTokenSource? _cancellationTokenSource;
|
||
private bool _isDisposed = false;
|
||
|
||
protected override void OnInitialized()
|
||
{
|
||
_cancellationTokenSource = new CancellationTokenSource();
|
||
}
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
await LoadData();
|
||
}
|
||
|
||
private async Task LoadData()
|
||
{
|
||
if (_isDisposed) return;
|
||
|
||
try
|
||
{
|
||
_isLoading = true;
|
||
StateHasChanged();
|
||
|
||
// Load all teams for columns
|
||
_allTeams = await Context.Teams
|
||
.AsNoTracking()
|
||
.Include(t => t.Event)
|
||
.OrderBy(t => t.Event.Name)
|
||
.ThenBy(t => t.Identifier)
|
||
.ToListAsync();
|
||
|
||
// Load meeting histories
|
||
await RefreshMeetingHistories();
|
||
|
||
// Calculate times met
|
||
CalculateTimesMet();
|
||
}
|
||
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 RefreshMeetingHistories()
|
||
{
|
||
_meetingHistories = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync()).ToList();
|
||
|
||
// Extract all unique teams from meeting histories and merge with all teams
|
||
var teamsInHistory = _meetingHistories
|
||
.SelectMany(mh => mh.Teams)
|
||
.DistinctBy(t => t.Id)
|
||
.ToList();
|
||
|
||
// Merge with all teams, ensuring all teams are included
|
||
var allTeamIds = _allTeams.Select(t => t.Id).ToHashSet();
|
||
var newTeams = teamsInHistory.Where(t => !allTeamIds.Contains(t.Id)).ToList();
|
||
_allTeams = _allTeams.Concat(newTeams).OrderBy(t => t.Event?.Name ?? "").ThenBy(t => t.Identifier).ToList();
|
||
}
|
||
|
||
private void CalculateTimesMet()
|
||
{
|
||
_timesMetDict.Clear();
|
||
|
||
foreach (var team in _allTeams)
|
||
{
|
||
_timesMetDict[team.Id] = 0;
|
||
}
|
||
|
||
foreach (var history in _meetingHistories)
|
||
{
|
||
var teamIds = history.Teams.Select(t => t.Id).ToHashSet();
|
||
foreach (var teamId in teamIds)
|
||
{
|
||
if (_timesMetDict.ContainsKey(teamId))
|
||
{
|
||
_timesMetDict[teamId]++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private int GetTimesMetForTeam(Team team)
|
||
{
|
||
return _timesMetDict.GetValueOrDefault(team.Id, 0);
|
||
}
|
||
|
||
private bool TeamMetOnDate(TeamMeetingHistory history, Team team)
|
||
{
|
||
return history.Teams.Any(t => t.Id == team.Id);
|
||
}
|
||
|
||
private async Task ViewMeetingDetails(TeamMeetingHistory history)
|
||
{
|
||
var parameters = new DialogParameters
|
||
{
|
||
["MeetingHistoryId"] = history.Id
|
||
};
|
||
|
||
var options = new DialogOptions
|
||
{
|
||
MaxWidth = MaxWidth.Large,
|
||
FullWidth = true,
|
||
CloseButton = true
|
||
};
|
||
|
||
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
|
||
var result = await dialog.Result;
|
||
|
||
if (result is { Canceled: false })
|
||
{
|
||
// Refresh data if meeting was updated or deleted
|
||
await RefreshMeetingHistories();
|
||
CalculateTimesMet();
|
||
if (!_isDisposed)
|
||
{
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task LoadMeetingIntoPlanner(TeamMeetingHistory history)
|
||
{
|
||
if (_isDisposed) 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 = history.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 = history.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>());
|
||
|
||
// Navigate to planner
|
||
NavigationManager.NavigateTo("/meeting-schedule");
|
||
|
||
if (!_isDisposed)
|
||
{
|
||
Snackbar.Add($"Loaded meeting from {history.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);
|
||
}
|
||
}
|
||
}
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (!_isDisposed)
|
||
{
|
||
_isDisposed = true;
|
||
_cancellationTokenSource?.Cancel();
|
||
_cancellationTokenSource?.Dispose();
|
||
_cancellationTokenSource = null;
|
||
}
|
||
await ValueTask.CompletedTask;
|
||
}
|
||
}
|