Compare commits

...
14 Commits
Author SHA1 Message Date
poprhythm 432caa0fe8 Refactor StateScheduleHandout component to improve event occurrence filtering and display 2026-04-08 22:11:31 -04:00
poprhythm 8d7c6b103c Enhance EventOccurrenceDetailsDialog and StateScheduleHandout components for improved event display and styling 2026-04-08 15:10:45 -04:00
poprhythm 5d2d019e87 Refactor Printout component to enhance student ordering and include captain information 2026-04-08 14:22:47 -04:00
poprhythm ea1bb70740 Update Printout component to improve student ordering logic 2026-04-07 14:01:51 -04:00
poprhythm 4401e4a3ec Refactor StateScheduleHandout component for improved print layout and styling 2026-04-07 08:39:14 -04:00
poprhythm a9036d5d04 Add state schedule handout feature and configuration options
This commit introduces a new StateScheduleHandout component for generating printable schedules for students, including a combined master list of events. It adds configuration options in appsettings.json for state abbreviations and special event filters, enhancing the scheduling functionality. The Program.cs file is updated to register the new StateScheduleHandoutOptions, and the Calendar and Teams components are modified to include links to the new handout feature. Additionally, utility methods for filtering event occurrences are implemented to support the new functionality, improving the overall user experience in managing state schedules.
2026-04-06 23:33:57 -04:00
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
poprhythm 336bbb1dec Refactor Event components for improved data handling and null safety
This commit updates the Edit.razor and EventAttributes.razor components to enhance data handling and prevent potential null reference exceptions. In Edit.razor, comments are added to clarify the tracking of related careers, ensuring that the same instances are used to avoid tracking conflicts. The EventAttributes.razor component is modified to handle null EventDefinition gracefully, preventing errors when the parameter is not set. Additionally, the EventDefinitionService is updated to ensure existing careers are retrieved with tracking, improving data consistency. These changes collectively enhance the robustness and reliability of the event management features.
2026-03-10 11:12:28 -04:00
poprhythm 675f04afec Add CalendarService and integrate calendar functionality into components
This commit introduces the CalendarService, which provides methods for retrieving all calendar items and upcoming events. The service is integrated into various components, including the Calendar and Home pages, enhancing the calendar functionality by allowing users to view upcoming events and meeting histories. Additionally, the Index.razor component is updated to parse query parameters for date selection, improving user experience. The layout of the MeetingHistoryDetailDialog and SaveMeetingHistoryDialog components is also refined for better organization and responsiveness. These changes collectively enhance the calendar feature and improve the overall user interface in managing events and meetings.
2026-01-27 22:38:52 -05:00
poprhythm 84eaf338a9 Enhance TeamMeetingHistoryDialog to display team members' attendance
This commit updates the TeamMeetingHistoryDialog component to show the attendance status of team members during meetings. It replaces the previous status display with a list of team members, indicating their presence or absence using MudBlazor chips. Additionally, the TeamMeetingHistoryService is modified to include student data for accurate attendance tracking. These changes improve the clarity and usability of the meeting history dialog, enhancing the overall user experience in managing team meetings.
2026-01-27 20:10:40 -05:00
poprhythm 15d7edec8f Add Team Meeting History functionality with dialog and badge components
This commit introduces a new feature to display meeting history for teams. It adds a `TeamMeetingHistoryDialog` component that shows the meeting history in a dialog format, including a loading state and error handling. Additionally, a `TeamMeetingHistoryBadge` component is created to display the count of meetings for each team, enhancing the user interface with tooltips for better interaction. The `Details` and `Index` components are updated to integrate these new features, allowing users to view meeting history directly from the team details and index pages. These changes improve the overall functionality and user experience in managing team meetings.
2026-01-26 23:31:59 -05:00
poprhythm 46836fde2e Refactor Calendar component to adjust default meeting times and improve event item styling
This commit updates the Calendar component by changing the default start time for meetings to 9:00 AM and the end time to 5:01 PM, ensuring better alignment with typical work hours. Additionally, the styling of calendar event items is refined by removing fixed width and height properties, allowing for more flexible rendering. These changes enhance the usability and visual presentation of the calendar feature, contributing to an improved user experience.
2026-01-26 22:15:03 -05:00
poprhythm d0ce71397b Update Calendar component to use two-way binding for view and current day properties
This commit modifies the Calendar component to implement two-way binding for the View and CurrentDay properties of the MudCalendar. By using `@bind-View` and `@bind-CurrentDay`, the component enhances data synchronization between the UI and underlying state, improving user interaction and responsiveness. This change aligns with ongoing efforts to refine the user experience in the calendar feature.
2026-01-26 21:47:06 -05:00
poprhythm 840a8edbf1 Enhance Calendar component with improved event tooltip functionality and styling
This commit updates the Calendar component to include a new method for generating tooltips for both events and meetings, enhancing user interaction by providing contextual information. Additionally, CSS styles are introduced for calendar event items, improving their appearance and hover effects. These changes contribute to a more informative and visually appealing calendar experience, aligning with the ongoing efforts to refine the user interface.
2026-01-26 21:31:18 -05:00
29 changed files with 1635 additions and 263 deletions
@@ -1 +1,72 @@
@namespace WebApp.Components.Features.Calendar
@using Core.Entities
<MudDialog>
<DialogContent>
@if (EventOccurrence == null)
{
<MudAlert Severity="Severity.Warning">
Event details are unavailable.
</MudAlert>
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.h6">
@(EventDefinition?.Name ?? EventOccurrence.Name)
</MudText>
<MudDivider />
<MudText Typo="Typo.body1">
<strong>Occurrence:</strong> @EventOccurrence.Name
</MudText>
<MudText Typo="Typo.body1">
<strong>Start:</strong> @EventOccurrence.StartTime.ToString("f")
</MudText>
@if (EventOccurrence.EndTime != null)
{
<MudText Typo="Typo.body1">
<strong>End:</strong> @EventOccurrence.EndTime.Value.ToString("f")
</MudText>
}
@if (!string.IsNullOrWhiteSpace(EventOccurrence.Location))
{
<MudText Typo="Typo.body1">
<strong>Location:</strong> @EventOccurrence.Location
</MudText>
}
@if (StudentFirstNames.Any())
{
<MudText Typo="Typo.body1">
<strong>Students:</strong> @string.Join(", ", StudentFirstNames)
</MudText>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
public IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public EventOccurrence? EventOccurrence { get; set; }
[Parameter]
public EventDefinition? EventDefinition { get; set; }
[Parameter]
public List<string> StudentFirstNames { get; set; } = [];
private void Close()
{
MudDialog.Close();
}
}
+47 -143
View File
@@ -5,9 +5,7 @@
@using Heron.MudCalendar @using Heron.MudCalendar
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using WebApp.Authentication @using WebApp.Authentication
@using Core.Utility @inject ICalendarService CalendarService
@inject IEventOccurrenceService EventOccurrenceService
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject ILogger<Index> Logger @inject ILogger<Index> Logger
@inject IDialogService DialogService @inject IDialogService DialogService
@@ -16,6 +14,9 @@
<MudTooltip Text="Import"> <MudTooltip Text="Import">
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton> <MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">Schedule handout</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator"> <AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Admin"> <MudTooltip Text="Admin">
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton> <MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
@@ -37,29 +38,30 @@
<MudCalendar T="CalendarItemWrapper" <MudCalendar T="CalendarItemWrapper"
Items="_calendarItems" Items="_calendarItems"
View="_currentView" @bind-View="_currentView"
CurrentDay="@_calendarDate" @bind-CurrentDay="_calendarDate"
Class="event-calendar" Class="event-calendar"
ItemClicked="OnItemClicked"> ItemClicked="OnItemClicked">
<MonthTemplate> <MonthTemplate>
@* <MudTooltip Text="@GetEventTooltip(context)"> *@ <MudTooltip Text="@GetEventTooltip(context)">
<div class="d-flex gap-1"> <div class="calendar-event-item">
<MudIcon Icon="@Icons.Material.Filled.Circle" Color="Color.Secondary" Size="Size.Small"/>
<div>@context.Text</div>
</div>
@* </MudTooltip> *@
</MonthTemplate>
<WeekTemplate>
@* <MudTooltip Text="@GetEventTooltip(context)"> *@
<div style="width: 100%; height: 100%;">
@context.Text @context.Text
</div> </div>
@* </MudTooltip> *@ </MudTooltip>
</MonthTemplate>
<WeekTemplate>
<MudTooltip Text="@GetEventTooltip(context)">
<div class="calendar-event-item">
@context.Text
</div>
</MudTooltip>
</WeekTemplate> </WeekTemplate>
<DayTemplate> <DayTemplate>
@* <MudTooltip Text="@GetEventTooltip(context)"> *@ <MudTooltip Text="@GetEventTooltip(context)">
<div>@context.Text</div> <div class="calendar-event-item">
@* </MudTooltip> *@ @context.Text
</div>
</MudTooltip>
</DayTemplate> </DayTemplate>
</MudCalendar> </MudCalendar>
</MudStack> </MudStack>
@@ -71,8 +73,17 @@
private DateTime _calendarDate = DateTime.Today; private DateTime _calendarDate = DateTime.Today;
private CalendarView _currentView = CalendarView.Month; private CalendarView _currentView = CalendarView.Month;
[SupplyParameterFromQuery]
private string? Date { get; set; }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Parse date from query parameter if provided
if (!string.IsNullOrEmpty(Date) && DateTime.TryParse(Date, out var parsedDate))
{
_calendarDate = parsedDate.Date;
}
await LoadCalendarEvents(); await LoadCalendarEvents();
} }
@@ -81,91 +92,8 @@
try try
{ {
Logger.LogInformation("Loading calendar events"); Logger.LogInformation("Loading calendar events");
var occurrences = await EventOccurrenceService.GetEventOccurrencesAsync(); _calendarItems = await CalendarService.GetAllCalendarItemsAsync();
Logger.LogInformation("Loaded {Count} calendar items", _calendarItems.Count);
var eventOccurrences = occurrences as EventOccurrence[] ?? occurrences.ToArray();
Logger.LogDebug("Received {Count} occurrences from service", eventOccurrences.Count());
// Get all unique event definition IDs that have occurrences
var eventDefinitionIds = eventOccurrences
.Where(occ => occ?.EventDefinition?.Id != null)
.Select(occ => occ!.EventDefinition!.Id)
.Distinct()
.ToList();
// Load teams for all event definitions
var teamsByEventId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
List<CalendarItemWrapper> items = [];
// Add event occurrences
foreach (var occ in eventOccurrences)
{
try
{
if (string.IsNullOrEmpty(occ.Name))
{
Logger.LogWarning("Occurrence with Id={Id} has null or empty Name", occ.Id);
}
// Get student first names for this event definition
var studentFirstNames = occ.EventDefinition != null && teamsByEventId.TryGetValue(occ.EventDefinition.Id, out var teams)
? TeamStudentNameFormatter.FormatStudentListForEvent(
occ.EventDefinition,
teams,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Star,
Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical
})
: [];
var calendarEventItem = new CalendarEventItem(occ, studentFirstNames);
items.Add(new CalendarItemWrapper(calendarEventItem));
}
catch (Exception ex)
{
Logger.LogError(ex, "Error creating CalendarEventItem for occurrence Id={Id}, Name={Name}",
occ?.Id, occ?.Name);
// Continue processing other items
}
}
// Load and add meeting histories
try
{
Logger.LogInformation("Loading meeting histories");
var meetingHistories = await TeamMeetingHistoryService.GetMeetingHistoriesAsync();
foreach (var meetingHistory in meetingHistories)
{
try
{
var calendarMeetingItem = new CalendarMeetingItem(meetingHistory);
items.Add(new CalendarItemWrapper(calendarMeetingItem));
}
catch (Exception ex)
{
Logger.LogError(ex, "Error creating CalendarMeetingItem for meeting history Id={Id}, Date={Date}",
meetingHistory?.Id, meetingHistory?.MeetingDate);
// Continue processing other items
}
}
Logger.LogInformation("Added {Count} meeting histories to calendar", meetingHistories.Count());
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading meeting histories");
// Continue - don't fail the entire calendar load if meetings fail
}
_calendarItems = items;
Logger.LogInformation("Created {Count} calendar items from {OccurrenceCount} occurrences and meetings",
_calendarItems.Count, eventOccurrences.Count());
// Find the next date with events
_calendarDate = GetNextDateWithEvents();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -179,50 +107,18 @@
} }
private DateTime GetNextDateWithEvents()
private string GetEventTooltip(CalendarItemWrapper wrapper)
{ {
try if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
{ {
if (_calendarItems == null || !_calendarItems.Any()) return GetEventTooltip(wrapper.EventItem);
{
Logger.LogDebug("No calendar items available, returning today's date");
return DateTime.Today;
}
var today = DateTime.Today;
var nextItem = _calendarItems
.Where(item =>
{
try
{
return item.Start.Date >= today;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error checking item date, skipping item");
return false;
}
})
.OrderBy(item => item.Start)
.FirstOrDefault();
if (nextItem != null)
{
return nextItem.Start.Date;
}
// Fallback to first item if no future items
var firstItem = _calendarItems
.OrderBy(item => item.Start)
.FirstOrDefault();
return firstItem != null ? firstItem.Start.Date : DateTime.Today;
} }
catch (Exception ex) else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
{ {
Logger.LogError(ex, "Error in GetNextDateWithEvents"); return GetMeetingTooltip(wrapper.MeetingItem);
return DateTime.Today;
} }
return wrapper.Text;
} }
private string GetEventTooltip(CalendarEventItem item) private string GetEventTooltip(CalendarEventItem item)
@@ -257,6 +153,14 @@
return string.Join("\n", parts); return string.Join("\n", parts);
} }
private string GetMeetingTooltip(CalendarMeetingItem item)
{
if (item.MeetingHistoryData == null)
return "Team Meeting";
return $"Team Meeting\nDate: {item.MeetingHistoryData.MeetingDate:g}";
}
private async Task OnItemClicked(CalendarItemWrapper wrapper) private async Task OnItemClicked(CalendarItemWrapper wrapper)
{ {
if (_calendarItems == null) if (_calendarItems == null)
@@ -0,0 +1,422 @@
@page "/calendar/state-schedule-handout"
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Options
@using System.Globalization
@using WebApp.Models
@using WebApp.Utility
@using WebApp.Services
@inject AppDbContext Context
@inject IConfiguration Configuration
@inject IOptionsMonitor<StateScheduleHandoutOptions> HandoutOptionsMonitor
@inject IEventOccurrenceService EventOccurrenceService
<div class="no-print">
<PageHeader
Title="State schedule handout"
Description="Print per-student schedules and the combined master list."
Icon="@Icons.Material.Filled.Print"
ShowBackButton="true"
BackButtonUrl="/calendar" />
</div>
@if (_students == null || _allOccurrences == null)
{
<p><em>Loading...</em></p>
}
else
{
var opts = HandoutOptionsMonitor.CurrentValue;
<MudContainer Class="state-schedule-handout">
@foreach (var student in _students)
{
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5">
@if (string.IsNullOrWhiteSpace(student.StateId))
{
@student.Name
}
else
{
@($"{student.Name} - {student.StateId}")
}
</MudText>
<MudText Typo="Typo.h6" Class="mb-3">
TSA @_competitionYear @_stateAbbrev State Schedule
</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-1">Events</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-4 nobrk">
<thead>
<tr>
<th>State ID</th>
<th>Event</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
@foreach (var eventRow in GetEventSummaryRows(student))
{
<tr>
<td>@eventRow.StateRegistrationId</td>
<td>@eventRow.EventName</td>
<td>@eventRow.Activity</td>
</tr>
}
</tbody>
</MudSimpleTable>
@{
var scheduleRows = BuildStudentSchedule(student, opts).ToList();
}
<MudText Typo="Typo.subtitle1" Class="mb-1">Schedule</MudText>
@if (scheduleRows.Count == 0)
{
<MudText Class="mud-text-secondary">No schedule entries for imported occurrences.</MudText>
}
else
{
@foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var occ in dateGroup.OrderBy(o => o.StartTime))
{
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatEventColumn(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudContainer>
}
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5" Class="mb-2">Combined schedule</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">Imported occurrences relevant to this chapter.</MudText>
@{
var combinedOccurrences = GetCombinedScheduleOccurrences().ToList();
}
@if (combinedOccurrences.Count == 0)
{
<MudText Class="mud-text-secondary">No relevant event occurrences found for your current team registrations.</MudText>
}
@foreach (var dateGroup in combinedOccurrences.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var tlGroup in dateGroup
.OrderBy(o => o.StartTime)
.GroupBy(o => (FormatTimeDisplay(o), o.Location ?? ""))
.Select(g => g.ToList()))
{
if (tlGroup.Count == 1)
{
var occ = tlGroup[0];
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatCombinedScheduleEventCell(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
else
{
var genericOcc = tlGroup.FirstOrDefault(o => !o.EventDefinitionId.HasValue);
var specificOccs = tlGroup
.Where(o => o.EventDefinitionId.HasValue)
.OrderBy(o => FormatEventColumn(o), StringComparer.OrdinalIgnoreCase)
.ToList();
var rowCount = (genericOcc != null ? 1 : 0) + specificOccs.Count;
var representative = genericOcc ?? specificOccs[0];
if (genericOcc != null)
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td>@FormatCombinedScheduleEventCell(genericOcc)</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs)
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
else
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(specificOccs[0])</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs.Skip(1))
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
}
}
</tbody>
</MudSimpleTable>
}
</MudContainer>
</MudContainer>
}
@code {
private Student[]? _students;
private List<EventOccurrence>? _allOccurrences;
private Dictionary<int, List<Team>> _teamsByEventDefinitionId = new();
private string _competitionYear = "";
private string _stateAbbrev = "";
private string? _chapterStateId;
protected override async Task OnInitializedAsync()
{
_competitionYear = Configuration["ChapterSettings:CompetitionYear"] ?? "";
_stateAbbrev = Configuration["ChapterSettings:StateAbbrev"] ?? "ST";
_chapterStateId = Configuration["ChapterSettings:StateId"];
_allOccurrences = await Context.EventOccurrences
.AsNoTracking()
.Include(eo => eo.EventDefinition)
.OrderBy(eo => eo.StartTime)
.ToListAsync();
var eventDefIds = _allOccurrences
.Where(o => o.EventDefinitionId.HasValue)
.Select(o => o.EventDefinitionId!.Value)
.Distinct()
.ToList();
_teamsByEventDefinitionId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefIds);
// Tracking required: Include Teams->Students creates a graph cycle (StudentTeamStudent) that EF disallows with AsNoTracking().
_students = await Context.Students
.Include(s => s.Teams)
.ThenInclude(t => t!.Event)
.Include(s => s.Teams)
.ThenInclude(t => t!.Captain)
.Include(s => s.Teams)
.ThenInclude(t => t!.Students)
.OrderBy(s => s.FirstName)
.ThenBy(s => s.LastName)
.ToArrayAsync();
}
private IEnumerable<EventOccurrence> BuildStudentSchedule(Student student, StateScheduleHandoutOptions opts)
{
var eventIds = student.Teams.Select(t => t.Event.Id).ToHashSet();
var competition = _allOccurrences!
.Where(o => o.EventDefinitionId.HasValue && eventIds.Contains(o.EventDefinitionId.Value))
.Where(o => StateScheduleOccurrenceFilter.IncludeCompetitionOccurrenceForStudent(o, opts));
var special = _allOccurrences!
.Where(o => o.EventDefinitionId == null)
.Where(o => StateScheduleOccurrenceFilter.IncludeSpecialOccurrenceForStudent(o, student, opts));
return competition
.Concat(special)
.OrderBy(o => o.StartTime)
.DistinctBy(o => (o.StartTime, o.Name ?? ""));
}
private IEnumerable<EventOccurrence> GetCombinedScheduleOccurrences()
{
return _allOccurrences!
.Where(o =>
{
// Keep chapter-wide/special schedule rows.
if (!o.EventDefinitionId.HasValue)
return true;
// Keep only competition events where this chapter has registered teams.
return _teamsByEventDefinitionId.TryGetValue(o.EventDefinitionId.Value, out var teams) && teams.Count > 0;
})
.OrderBy(o => o.StartTime);
}
private IEnumerable<EventSummaryRow> GetEventSummaryRows(Student student)
{
foreach (var team in student.Teams.OrderBy(t => t.Event.Name))
{
yield return new EventSummaryRow(
StateRegistrationId: FormatStateRegistrationId(team, student),
EventName: team.Event.Name,
Activity: FormatActivitySummary(team, student));
}
}
/// <summary>
/// Team events: chapter <c>ChapterSettings:StateId</c> + <see cref="Team.Identifier"/> (e.g. 12227-1).
/// Individual events: competitor's <see cref="Student.StateId"/>.
/// </summary>
private string FormatStateRegistrationId(Team team, Student student)
{
if (team.Event.EventFormat == EventFormat.Individual)
{
return string.IsNullOrWhiteSpace(student.StateId)
? "—"
: student.StateId.Trim();
}
var chap = _chapterStateId?.Trim();
var ident = team.Identifier?.Trim();
if (string.IsNullOrEmpty(chap) && string.IsNullOrEmpty(ident))
return "—";
// Already a full registration id (e.g. "12227-1" or state id stored on team)
if (!string.IsNullOrEmpty(ident))
{
if (ident.Contains('-', StringComparison.Ordinal))
return ident;
if (!string.IsNullOrEmpty(chap) && ident.StartsWith(chap, StringComparison.Ordinal))
return ident;
}
if (!string.IsNullOrEmpty(chap) && !string.IsNullOrEmpty(ident))
return $"{chap}-{ident}";
return !string.IsNullOrEmpty(chap) ? chap : ident!;
}
// Activity line comes from event SemifinalistActivity (interview/presentation limits), not Min/MaxTeamSize.
private static string FormatActivitySummary(Team team, Student student)
{
var parts = new List<string>();
if (team.Captain?.Id == student.Id)
parts.Add("(Cpt.)");
if (!string.IsNullOrWhiteSpace(team.Event.SemifinalistActivity))
parts.Add(team.Event.SemifinalistActivity!);
return string.Join(" ", parts).Trim();
}
private static string FormatDateHeading(DateTime date) =>
date.ToString("MMMM d, dddd", CultureInfo.GetCultureInfo("en-US"));
private static string FormatTimeDisplay(EventOccurrence o)
{
if (!string.IsNullOrWhiteSpace(o.Time))
return o.Time.Trim();
return o.StartTime.ToString("g", CultureInfo.GetCultureInfo("en-US"));
}
private static string FormatEventColumn(EventOccurrence o)
{
if (o.EventDefinition != null)
{
var ev = !string.IsNullOrWhiteSpace(o.EventDefinition.ShortName)
? o.EventDefinition.ShortName
: o.EventDefinition.Name;
if (string.IsNullOrWhiteSpace(o.Name))
return ev;
if (o.Name.Contains(ev, StringComparison.OrdinalIgnoreCase))
return o.Name.Trim();
return $"{ev} {o.Name}".Trim();
}
return string.IsNullOrWhiteSpace(o.Name) ? (o.SpecialEventType ?? "") : o.Name.Trim();
}
private string FormatCombinedScheduleEventCell(EventOccurrence occ)
{
var baseText = FormatEventColumn(occ);
if (!occ.EventDefinitionId.HasValue)
return baseText;
if (!_teamsByEventDefinitionId.TryGetValue(occ.EventDefinitionId.Value, out var teams) || teams.Count == 0)
return baseText;
var isIndividual = occ.EventDefinition?.EventFormat == EventFormat.Individual;
var orderedTeams = teams
.OrderBy(t => t, Comparer<Team>.Create((a, b) =>
{
var cmp = CombinedScheduleTeamSortOrder(a, b);
return cmp != 0 ? cmp : a.Id.CompareTo(b.Id);
}))
.ToList();
var rosterStrings = orderedTeams
.Select(t => FormatCombinedScheduleTeamRoster(t, isIndividual))
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
if (rosterStrings.Count == 0)
return baseText;
var suffix = rosterStrings.Count == 1
? rosterStrings[0]
: string.Join(" ", rosterStrings.Select(r => $"[{r}]"));
return $"{baseText} — {suffix}";
}
private static int CombinedScheduleTeamSortOrder(Team a, Team b)
{
var ka = a.Identifier?.Trim() ?? "";
var kb = b.Identifier?.Trim() ?? "";
if (int.TryParse(ka, out var na) && int.TryParse(kb, out var nb))
return na.CompareTo(nb);
return string.Compare(ka, kb, StringComparison.OrdinalIgnoreCase);
}
private static string FormatCombinedScheduleTeamRoster(Team team, bool isIndividual)
{
var students = team.Students?.ToList() ?? [];
if (students.Count == 0)
return "";
if (isIndividual)
{
var ordered = students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
return string.Join(", ", ordered.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
}
var cap = team.Captain;
var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id);
IEnumerable<Student> orderedTeam = capInRoster
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
: students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
}
private static string FormatCombinedScheduleStudentSegment(Student student, Team team, bool isIndividual)
{
if (isIndividual)
{
var sid = student.StateId?.Trim();
return !string.IsNullOrEmpty(sid)
? $"{student.FirstName} ({sid})"
: student.FirstName;
}
var isCpt = team.Captain?.Id == student.Id;
return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName;
}
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
}
@@ -1,5 +1,9 @@
@using WebApp.Models @using WebApp.Models
@if (EventDefinition is null)
{
return;
}
@* @if (EventDefinition.LevelOfEffort.HasValue) @* @if (EventDefinition.LevelOfEffort.HasValue)
{ {
<span class="numberCircle">@EventDefinition.LevelOfEffort</span> <span class="numberCircle">@EventDefinition.LevelOfEffort</span>
@@ -27,12 +31,17 @@
@code { @code {
[Parameter] [Parameter]
public required EventDefinition EventDefinition { get; set; } public EventDefinition? EventDefinition { get; set; }
private string _attributes = string.Empty; private string _attributes = string.Empty;
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
if (EventDefinition is null)
{
_attributes = string.Empty;
return;
}
_attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " "; _attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " ";
_attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " "; _attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " ";
_attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " "; _attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " ";
+6 -3
View File
@@ -1,4 +1,4 @@
@page "/events/edit" @page "/events/edit"
@attribute [Authorize] @attribute [Authorize]
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@@ -163,9 +163,10 @@
{ {
try try
{ {
// Get the tracked entity from the database // Get the tracked entity from the database (do not Include RelatedCareers:
// the same context may already be tracking those Career instances from the initial load,
// which would cause "another instance with the same key value is already being tracked").
var trackedEntity = await context.Events var trackedEntity = await context.Events
.Include(e => e.RelatedCareers)
.FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id); .FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
if (trackedEntity == null) if (trackedEntity == null)
@@ -177,6 +178,8 @@
// Update scalar properties from the form-bound entity // Update scalar properties from the form-bound entity
context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!); context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!);
// RelatedCareersText is not mapped; copy it so ProcessRelatedCareersAsync can use it
trackedEntity.RelatedCareersText = EventDefinition!.RelatedCareersText;
// Normalize and process related careers // Normalize and process related careers
await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity); await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity);
@@ -87,6 +87,13 @@
AddTooltip="Add regional event teams" AddTooltip="Add regional event teams"
RemoveTooltip="Remove regional event teams" /> RemoveTooltip="Remove regional event teams" />
</MudItem> </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"> <MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Individual" <AddRemoveFilter Label="Individual"
OnAdd="AddIndividual" OnAdd="AddIndividual"
@@ -246,6 +253,13 @@
StateHasChanged(); StateHasChanged();
} }
private void AddPresubmission()
{
var presubmissionTeams = _teams.Where(t => t.Event?.Presubmission == true);
_scheduledTeams = _scheduledTeams.Concat(presubmissionTeams).Distinct();
StateHasChanged();
}
private void AddHighLevelOfEffort() private void AddHighLevelOfEffort()
{ {
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams); _scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
@@ -266,6 +280,16 @@
StateHasChanged(); 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() private void AddIndividual()
{ {
var individualTeams = _teams.Where(t => t.Event.EventFormat == EventFormat.Individual); var individualTeams = _teams.Where(t => t.Event.EventFormat == EventFormat.Individual);
@@ -1,9 +1,5 @@
@namespace WebApp.Components.Features.MeetingSchedule @namespace WebApp.Components.Features.MeetingSchedule
@using Core.Entities
@using Core.Services @using Core.Services
@using Core.Utility
@using WebApp.Services
@using WebApp.Components.Shared.Components
@using WebApp.Models @using WebApp.Models
@inject ITeamMeetingHistoryService TeamMeetingHistoryService @inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject INotesService NotesService @inject INotesService NotesService
@@ -51,39 +47,42 @@
</MudTooltip> </MudTooltip>
</MudStack> </MudStack>
<MudText Typo="Typo.subtitle1">Teams That Met (@_meetingHistory.Teams.Count)</MudText> <MudGrid>
<MudPaper Elevation="1" Class="pa-2"> <MudItem xs="12" md="6">
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap"> <MudText Typo="Typo.subtitle1">Teams That Met (@_meetingHistory.Teams.Count)</MudText>
@foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString())) <MudPaper Elevation="1" Class="pa-2">
{ <MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="@AppIcons.TeamChipVariant()" Class="mx-1 my-1">@team.ToString()</MudChip> @foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
} {
</MudStack> <MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="@AppIcons.TeamChipVariant()" Class="mx-1 my-1">@team.ToString()</MudChip>
</MudPaper> }
</MudStack>
<MudDivider /> </MudPaper>
</MudItem>
<MudText Typo="Typo.subtitle1">Students (@GetAllStudentsFromTeams().Count)</MudText> <MudItem xs="12" md="6">
<MudPaper Elevation="1" Class="pa-2"> <MudText Typo="Typo.subtitle1">Students (@GetAllStudentsFromTeams().Count)</MudText>
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap"> <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); var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
} var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName);
@foreach (var student in allStudents) }
{ @foreach (var student in allStudents)
var isPresent = presentStudentIds.Contains(student.Id); {
<MudChip T="string" var isPresent = presentStudentIds.Contains(student.Id);
Size="Size.Small" <MudChip T="string"
Color="Color.Default" Size="Size.Small"
Variant="@AppIcons.StudentChipVariant()" Color="Color.Default"
Class="mx-1 my-1" Variant="@AppIcons.StudentChipVariant()"
Style="@(!isPresent ? "opacity: 0.5;" : "")"> Class="mx-1 my-1"
@student.FirstNameLastName Style="@(!isPresent ? "opacity: 0.5;" : "")">
</MudChip> @student.FirstNameLastName
} </MudChip>
</MudStack> }
</MudPaper> </MudStack>
</MudPaper>
</MudItem>
</MudGrid>
@if (_meetingNote != null) @if (_meetingNote != null)
{ {
@@ -33,23 +33,26 @@
<MudDivider /> <MudDivider />
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;"> <MudGrid>
<TeamToggleSelector Teams="@AllTeams" <MudItem xs="12" md="6">
SelectedTeams="_selectedTeams" <MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
SelectedTeamsChanged="OnTeamsChanged" <TeamToggleSelector Teams="@AllTeams"
Title="Teams That Met" SelectedTeams="_selectedTeams"
ShowEventAttributes="false" /> SelectedTeamsChanged="OnTeamsChanged"
</MudPaper> Title="Teams That Met"
ShowEventAttributes="false" />
<MudDivider /> </MudPaper>
</MudItem>
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;"> <MudItem xs="12" md="6">
<StudentToggleSelector Students="@AllStudents" <MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
SelectedStudents="_selectedStudents" <StudentToggleSelector Students="@AllStudents"
SelectedStudentsChanged="OnStudentsChanged" SelectedStudents="_selectedStudents"
Title="Students Present" SelectedStudentsChanged="OnStudentsChanged"
ShowFullName="true" /> Title="Students Present"
</MudPaper> ShowFullName="true" />
</MudPaper>
</MudItem>
</MudGrid>
<MudDivider /> <MudDivider />
@@ -149,11 +152,28 @@
// Match teams by ID to ensure reference equality with MudToggleGroup // Match teams by ID to ensure reference equality with MudToggleGroup
var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet(); var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet();
_selectedTeams = AllTeams.Where(t => selectedTeamIds.Contains(t.Id)); var matchedTeams = AllTeams.Where(t => selectedTeamIds.Contains(t.Id)).ToList();
// If we couldn't match all teams from AllTeams, use the teams from existing history
// This can happen if AllTeams is empty or doesn't contain all the teams
if (matchedTeams.Count != existingHistory.Teams.Count && AllTeams.Any())
{
// Try to match what we can, but log a warning
System.Diagnostics.Debug.WriteLine($"Warning: Could not match all teams. Expected {existingHistory.Teams.Count}, matched {matchedTeams.Count}");
}
_selectedTeams = matchedTeams.Any() ? matchedTeams : existingHistory.Teams;
// Match students by ID to ensure reference equality with MudToggleGroup // Match students by ID to ensure reference equality with MudToggleGroup
var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet(); var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet();
_selectedStudents = AllStudents.Where(s => selectedStudentIds.Contains(s.Id)); var matchedStudents = AllStudents.Where(s => selectedStudentIds.Contains(s.Id)).ToList();
// If we couldn't match all students from AllStudents, use the students from existing history
if (matchedStudents.Count != existingHistory.Students.Count && AllStudents.Any())
{
// Try to match what we can, but log a warning
System.Diagnostics.Debug.WriteLine($"Warning: Could not match all students. Expected {existingHistory.Students.Count}, matched {matchedStudents.Count}");
}
_selectedStudents = matchedStudents.Any() ? matchedStudents : existingHistory.Students;
// Load existing note if available // Load existing note if available
var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate); var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate);
@@ -247,6 +267,13 @@
return; return;
} }
// Validate that we have at least one team selected
if (!_selectedTeams.Any())
{
Snackbar.Add("Please select at least one team", Severity.Warning);
return;
}
// Check if a meeting history already exists for this date (only when creating new, not editing) // Check if a meeting history already exists for this date (only when creating new, not editing)
if (!_isEditMode) if (!_isEditMode)
{ {
@@ -344,10 +371,19 @@
return; return;
} }
meetingHistory = existingHistory; // Ensure we have teams and students - use existing if selected lists are empty (fallback)
meetingHistory.MeetingDate = _meetingDate.Value; var teamsToSave = _selectedTeams.Any() ? _selectedTeams.ToList() : existingHistory.Teams.ToList();
meetingHistory.Teams = _selectedTeams.ToList(); var studentsToSave = _selectedStudents.Any() ? _selectedStudents.ToList() : existingHistory.Students.ToList();
meetingHistory.Students = _selectedStudents.ToList();
// Create a new meeting history object with the updated data
// Use IDs to ensure we're working with the correct entities
meetingHistory = new TeamMeetingHistory
{
Id = existingHistory.Id,
MeetingDate = _meetingDate.Value,
Teams = teamsToSave,
Students = studentsToSave
};
await TeamMeetingHistoryService.UpdateMeetingHistoryAsync(meetingHistory); await TeamMeetingHistoryService.UpdateMeetingHistoryAsync(meetingHistory);
@@ -0,0 +1,108 @@
@namespace WebApp.Components.Features.Teams.Components
@using Core.Entities
@using WebApp.Services
@using MudBlazor
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject IDialogService DialogService
@implements IAsyncDisposable
@if (_meetingCount.HasValue && _meetingCount.Value > 0)
{
<MudTooltip Text="View Meeting History">
<MudBadge Content="@_meetingCount.Value.ToString()"
Color="Color.Primary"
Overlap="true">
<MudIconButton Icon="@Icons.Material.Filled.History"
Size="Size.Small"
Color="Color.Default"
OnClick="OpenMeetingHistoryDialog"
Variant="Variant.Text" />
</MudBadge>
</MudTooltip>
}
@code {
[Parameter]
public int TeamId { get; set; }
[Parameter]
public string TeamName { get; set; } = string.Empty;
private int? _meetingCount;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadMeetingCount();
}
private async Task LoadMeetingCount()
{
if (_isDisposed) return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
_meetingCount = await TeamMeetingHistoryService.GetMeetingHistoryCountForTeamAsync(TeamId);
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception)
{
// Ignore errors - just don't show the badge
_meetingCount = null;
}
finally
{
if (!_isDisposed)
{
StateHasChanged();
}
}
}
private async Task OpenMeetingHistoryDialog()
{
if (_isDisposed) return;
var parameters = new DialogParameters
{
["TeamId"] = TeamId,
["TeamName"] = TeamName
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
CloseButton = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<TeamMeetingHistoryDialog>($"{TeamName} Meeting History", parameters, options);
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -1,5 +1,6 @@
@using WebApp.Models @using WebApp.Models
@using Core.Utility @using Core.Utility
@using WebApp.Components.Features.Teams.Components
@if (Title != null) @if (Title != null)
{ {
@@ -28,14 +29,17 @@
@if (IsSelected(team)) @if (IsSelected(team))
{ {
var isExtended = IsExtended(team); var isExtended = IsExtended(team);
<MudTooltip Text="@(isExtended ? "Remove from extended teams" : "Extend to 2 time slots")"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIconButton Icon="@(isExtended ? Icons.Material.Filled.AddCircle : Icons.Material.Filled.Add)" <MudTooltip Text="@(isExtended ? "Remove from extended teams" : "Extend to 2 time slots")">
Size="Size.Small" <MudIconButton Icon="@(isExtended ? Icons.Material.Filled.AddCircle : Icons.Material.Filled.Add)"
Color="@(isExtended ? Color.Primary : Color.Default)" Size="Size.Small"
Variant="@(isExtended ? Variant.Filled : Variant.Text)" Color="@(isExtended ? Color.Primary : Color.Default)"
OnClick="@(() => ToggleExtended(team))" Variant="@(isExtended ? Variant.Filled : Variant.Text)"
Style="margin-left: 4px; padding: 2px;" /> OnClick="@(() => ToggleExtended(team))"
</MudTooltip> Style="margin-left: 4px; padding: 2px;" />
</MudTooltip>
<TeamMeetingHistoryBadge TeamId="@team.Id" TeamName="@team.ToString()" />
</MudStack>
} }
@if (ShowEventAttributes) @if (ShowEventAttributes)
{ {
@@ -3,9 +3,11 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@using WebApp.Components.Features.Teams.Components @using WebApp.Components.Features.Teams.Components
@using MudBlazor
@inject AppDbContext Context @inject AppDbContext Context
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject IDialogService DialogService
@if (Team is null) @if (Team is null)
{ {
@@ -29,6 +31,11 @@
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")" Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")"
Variant="Variant.Outlined">Edit</MudButton> Variant="Variant.Outlined">Edit</MudButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="View Meeting History">
<MudButton StartIcon="@Icons.Material.Filled.History"
OnClick="ShowMeetingHistory"
Variant="Variant.Outlined">Meeting History</MudButton>
</MudTooltip>
</div> </div>
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -86,4 +93,26 @@
{ {
await JSRuntime.InvokeVoidAsync("window.print"); await JSRuntime.InvokeVoidAsync("window.print");
} }
private async Task ShowMeetingHistory()
{
if (Team == null) return;
var teamName = Team.ToString();
var parameters = new DialogParameters
{
["TeamId"] = Team.Id,
["TeamName"] = teamName
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
CloseButton = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<TeamMeetingHistoryDialog>($"{teamName} Meeting History", parameters, options);
}
} }
+6 -2
View File
@@ -19,6 +19,9 @@
<MudTooltip Text="Handout"> <MudTooltip Text="Handout">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="State schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.CalendarMonth" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">State schedule</MudButton>
</MudTooltip>
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")"> <MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt" <MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)" Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
@@ -47,11 +50,12 @@
<CellTemplate> <CellTemplate>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1"> <MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
<MudLink Href="@($"/teams/details?id={context.Item.Id}&returnUrl=/teams")" <MudLink Href="@($"/teams/details?id={context.Item.Id}&returnUrl=/teams")"
Underline="Underline.Hover" Underline="Underline.Hover"
Color="Color.Primary"> Color="Color.Primary">
@context.Item.ToString() @context.Item.ToString()
</MudLink> </MudLink>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<TeamMeetingHistoryBadge TeamId="@context.Item.Id" TeamName="@context.Item.ToString()" />
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit" <IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
TooltipText="Edit" TooltipText="Edit"
Href="@($"/teams/edit?id={context.Item.Id}&returnUrl=/teams")" /> Href="@($"/teams/edit?id={context.Item.Id}&returnUrl=/teams")" />
@@ -41,9 +41,8 @@ else
@{ @{
var students var students
= context.Students = context.Students
.OrderByDescending(s => s == context.Captain) .OrderByDescending(s => context.Captain != null && context.Captain.Equals(s))
.ThenBy(s => s.EventRankings.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue) .ThenByDescending(e => e.Grade + e.TsaYear)
.ThenByDescending(e => e.Grade)
.ThenBy(e => e.FirstName) .ThenBy(e => e.FirstName)
.ToArray(); .ToArray();
} }
@@ -233,6 +232,7 @@ else
.AsNoTracking() .AsNoTracking()
.Include(e => e.Event) .Include(e => e.Event)
.Include(e => e.Students) .Include(e => e.Students)
.Include(e => e.Captain)
.OrderByEventFormatFirst() .OrderByEventFormatFirst()
.ThenBy(e => e.Event.Name) .ThenBy(e => e.Event.Name)
.ThenBy(e => e.Identifier ?? "") .ThenBy(e => e.Identifier ?? "")
@@ -244,6 +244,8 @@ else
.AsNoTracking() .AsNoTracking()
.Include(e => e.Teams) .Include(e => e.Teams)
.ThenInclude(e => e.Captain) .ThenInclude(e => e.Captain)
.Include(e => e.Teams)
.ThenInclude(e => e.Event)
.Include(e => e.EventRankings) .Include(e => e.EventRankings)
.ThenInclude(e => e.EventDefinition) .ThenInclude(e => e.EventDefinition)
.OrderBy(e => e.FirstName).ToArrayAsync(); .OrderBy(e => e.FirstName).ToArrayAsync();
@@ -0,0 +1,151 @@
@namespace WebApp.Components.Features.Teams
@using Core.Entities
@using WebApp.Services
@using Microsoft.AspNetCore.Components
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using Core.Utility
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@implements IAsyncDisposable
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">@TeamName Meeting History</MudText>
</TitleContent>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (!_meetingHistories.Any())
{
<MudAlert Severity="Severity.Info">No meeting history found for this team.</MudAlert>
}
else
{
<MudTable Items="_meetingHistories" Hover="true" Dense="true">
<HeaderContent>
<MudTh>Date</MudTh>
<MudTh>Team Members</MudTh>
</HeaderContent>
<RowTemplate>
@{
var team = context.Teams.FirstOrDefault(t => t.Id == TeamId);
var presentStudentIds = context.Students.Select(s => s.Id).ToHashSet();
var absentStudents = team?.Students.Where(s => !presentStudentIds.Contains(s.Id)).ToList() ?? [];
}
<MudTd>@context.MeetingDate.ToString("MM/dd/yyyy")</MudTd>
<MudTd>
@if (team != null)
{
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
@foreach (var student in team.Students)
{
var isAbsent = absentStudents.Contains(student);
var formattedName = TeamStudentNameFormatter.FormatStudentName(
student,
team,
new TeamStudentNameFormatter.FormatOptions
{
MarkAbsent = true,
AbsentStudents = absentStudents
});
<MudChip T="string"
Size="Size.Small"
Color="Color.Default"
Variant="@AppIcons.StudentChipVariant()"
Style="@(isAbsent ? "opacity: 0.5;" : "")">
<span>@formattedName</span>
</MudChip>
}
</MudStack>
}
</MudTd>
</RowTemplate>
</MudTable>
}
</DialogContent>
<DialogActions>
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public int TeamId { get; set; }
[Parameter]
public string TeamName { get; set; } = string.Empty;
private List<TeamMeetingHistory> _meetingHistories = [];
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadMeetingHistories();
}
private async Task LoadMeetingHistories()
{
if (_isDisposed) return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var histories = await TeamMeetingHistoryService.GetMeetingHistoriesForTeamAsync(TeamId);
_meetingHistories = histories.ToList();
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
System.Diagnostics.Debug.WriteLine($"Error loading meeting histories: {ex.Message}");
}
}
finally
{
if (!_isDisposed)
{
_isLoading = false;
StateHasChanged();
}
}
}
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;
}
}
+170 -19
View File
@@ -5,10 +5,14 @@
@using Core.Entities @using Core.Entities
@using WebApp.Services @using WebApp.Services
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@using Heron.MudCalendar
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject AppDbContext Context @inject AppDbContext Context
@inject INotesService NotesService @inject INotesService NotesService
@inject IDialogService DialogService @inject IDialogService DialogService
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject ICalendarService CalendarService
@inject NavigationManager Navigation
@implements IAsyncDisposable @implements IAsyncDisposable
<PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle> <PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle>
@@ -124,13 +128,76 @@ else
<MudGrid> <MudGrid>
<DashboardCard Icon="@AppIcons.Scheduler" <DashboardCard Icon="@AppIcons.Scheduler"
Title="Meeting Schedule Planner" Title="Meeting Schedule Planner"
Caption="Optimize meeting times" NavigateUrl="/meeting-schedule">
NavigateUrl="/meeting-schedule"/> @if (_recentMeetings.Any())
{
<MudGrid Spacing="2">
<MudItem xs="12" sm="12" md="7">
<MudText Typo="Typo.subtitle2" Class="mb-2" Style="font-weight: 600;">Recent Meetings</MudText>
<MudStack Spacing="1">
@foreach (var meeting in _recentMeetings)
{
<div @onclick="@(() => ShowMeetingDetails(meeting))"
@onclick:stopPropagation="true"
style="cursor: pointer; color: var(--mud-palette-primary);">
<MudText Typo="Typo.body2" Style="text-decoration: underline;">
@meeting.MeetingDate.ToString("MMM d, yyyy")
</MudText>
</div>
}
</MudStack>
</MudItem>
<MudItem xs="12" sm="12" md="5">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<div @onclick:stopPropagation="true">
<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>
</div>
</MudStack>
</MudItem>
</MudGrid>
}
else
{
<MudText Typo="Typo.caption" Class="mt-2">Optimize meeting times</MudText>
}
</DashboardCard>
<DashboardCard Icon="@AppIcons.EventCalendar" <DashboardCard Icon="@AppIcons.EventCalendar"
Title="Event Calendar" Title="Event Calendar"
Caption="Conference schedules" NavigateUrl="/calendar">
NavigateUrl="/calendar"/> @if (_nextCalendarItems.Any())
{
<MudText Typo="Typo.subtitle2" Class="mb-2" Style="font-weight: 600;">Next Events</MudText>
<MudStack Spacing="1">
@foreach (var item in _nextCalendarItems)
{
var itemDate = item.Start.Date;
var itemName = item.ItemType == CalendarItemType.Event && item.EventItem != null
? (item.EventItem.EventDefinition?.ShortName ?? item.EventItem.EventOccurrenceData?.Name ?? "Event")
: "Team Meeting";
var dateStr = itemDate.ToString("yyyy-MM-dd");
<div @onclick="@(() => NavigateToCalendar(dateStr))"
@onclick:stopPropagation="true"
style="cursor: pointer; color: var(--mud-palette-primary);">
<MudText Typo="Typo.body2" Style="text-decoration: underline;">
@itemName - @itemDate.ToString("MMM d, yyyy")
</MudText>
</div>
}
</MudStack>
}
else
{
<MudText Typo="Typo.caption" Class="mt-2">Conference schedules</MudText>
}
</DashboardCard>
</MudGrid> </MudGrid>
<MudPaper Elevation="0" Class="my-4"> <MudPaper Elevation="0" Class="my-4">
@@ -150,21 +217,6 @@ else
NavigateUrl="/students/teams"/> NavigateUrl="/students/teams"/>
</MudGrid> </MudGrid>
<MudPaper Elevation="0" Class="my-4">
<MudText Typo="Typo.h4">Team Building</MudText>
</MudPaper>
<MudGrid>
<DashboardCard Icon="@AppIcons.EventRank"
Title="Event Ranking"
Caption="Student event preferences"
NavigateUrl="/students/event-ranking"/>
<DashboardCard Icon="@AppIcons.TeamAssignment"
Title="Team Assignment"
Caption="Build optimal teams"
NavigateUrl="/teams/assignment"/>
</MudGrid>
<MudPaper Elevation="0" Class="my-4"> <MudPaper Elevation="0" Class="my-4">
<MudText Typo="Typo.h4">Chapter Data</MudText> <MudText Typo="Typo.h4">Chapter Data</MudText>
</MudPaper> </MudPaper>
@@ -189,6 +241,21 @@ else
Caption="@($"{_teamEventsCount} Team | {_individualEventsCount} Individual")" Caption="@($"{_teamEventsCount} Team | {_individualEventsCount} Individual")"
NavigateUrl="/events" /> NavigateUrl="/events" />
</MudGrid> </MudGrid>
<MudPaper Elevation="0" Class="my-4">
<MudText Typo="Typo.h4">Team Building</MudText>
</MudPaper>
<MudGrid>
<DashboardCard Icon="@AppIcons.EventRank"
Title="Event Ranking"
Caption="Student event preferences"
NavigateUrl="/students/event-ranking"/>
<DashboardCard Icon="@AppIcons.TeamAssignment"
Title="Team Assignment"
Caption="Build optimal teams"
NavigateUrl="/teams/assignment"/>
</MudGrid>
} }
@code { @code {
@@ -200,6 +267,9 @@ else
private int _teamCount; private int _teamCount;
private int _individualTeamsCount; private int _individualTeamsCount;
private int _groupTeamsCount; private int _groupTeamsCount;
private int _meetingHistoryCount;
private List<TeamMeetingHistory> _recentMeetings = [];
private List<CalendarItemWrapper> _nextCalendarItems = [];
private List<Note> _pinnedNotes = []; private List<Note> _pinnedNotes = [];
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false; private bool _isDisposed = false;
@@ -216,6 +286,8 @@ else
{ {
await LoadStatistics(); await LoadStatistics();
await LoadPinnedNotes(); await LoadPinnedNotes();
await LoadMeetingHistoryCount();
await LoadNextCalendarItem();
} }
private async Task HandleNoteChanged() private async Task HandleNoteChanged()
@@ -285,6 +357,85 @@ else
_groupTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Team); _groupTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Team);
} }
private async Task LoadMeetingHistoryCount()
{
if (_isDisposed) return;
try
{
var meetingHistories = await TeamMeetingHistoryService.GetMeetingHistoriesAsync();
var historiesList = meetingHistories.ToList();
_meetingHistoryCount = historiesList.Count;
// Get the 3 most recent meetings in descending order
_recentMeetings = historiesList
.OrderByDescending(m => m.MeetingDate)
.ThenByDescending(m => m.Id)
.Take(3)
.ToList();
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception)
{
// Error loading meeting history - ignore
_meetingHistoryCount = 0;
_recentMeetings = [];
}
}
private async Task ShowMeetingDetails(TeamMeetingHistory meeting)
{
var parameters = new DialogParameters
{
["MeetingHistoryId"] = meeting.Id
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
CloseButton = true,
MaxWidth = MaxWidth.Large,
FullWidth = true
};
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
}
private async Task LoadNextCalendarItem()
{
if (_isDisposed) return;
try
{
_nextCalendarItems = await CalendarService.GetUpcomingCalendarItemsAsync(3);
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception)
{
// Error loading calendar items - ignore
_nextCalendarItems = [];
}
}
private void NavigateToCalendar(string date)
{
Navigation.NavigateTo($"/calendar?date={date}");
}
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (!_isDisposed) if (!_isDisposed)
@@ -18,16 +18,16 @@
<MudNavLink Href="/students/teams" Icon="@AppIcons.Registration">Registration</MudNavLink> <MudNavLink Href="/students/teams" Icon="@AppIcons.Registration">Registration</MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavGroup Title="Team Building" Icon="@Icons.Material.Filled.GroupAdd" Expanded="false">
<MudNavLink Href="/students/event-ranking" Icon="@AppIcons.EventRank">Event Ranking</MudNavLink>
<MudNavLink Href="/teams/assignment" Icon="@AppIcons.TeamAssignment">Team Assignment</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Chapter Data" Icon="@Icons.Material.Filled.Storage" Expanded="false"> <MudNavGroup Title="Chapter Data" Icon="@Icons.Material.Filled.Storage" Expanded="false">
<MudNavLink Href="/students" Icon="@Icons.Material.Filled.People">Students</MudNavLink> <MudNavLink Href="/students" Icon="@Icons.Material.Filled.People">Students</MudNavLink>
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink> <MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavGroup Title="Team Building" Icon="@Icons.Material.Filled.GroupAdd" Expanded="false">
<MudNavLink Href="/students/event-ranking" Icon="@AppIcons.EventRank">Event Ranking</MudNavLink>
<MudNavLink Href="/teams/assignment" Icon="@AppIcons.TeamAssignment">Team Assignment</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false"> <MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink> <MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
</MudNavGroup> </MudNavGroup>
+4 -4
View File
@@ -30,9 +30,9 @@ public class CalendarMeetingItem : CalendarItem
MeetingHistoryData = meetingHistory; MeetingHistoryData = meetingHistory;
// Set base class properties that the calendar component uses // Set base class properties that the calendar component uses
Text = "Team Meeting"; Text = "Team Meeting";
// Use meeting date at 3:00 PM as default time // Set start to 9:00 AM on the meeting date
Start = meetingHistory.MeetingDate.Date.AddHours(15); Start = meetingHistory.MeetingDate.Date.AddHours(9);
// Default to 1 hour duration // Set end to 5:00 PM on the meeting date (5:01 PM to ensure it displays until 5pm if end is exclusive)
End = Start.AddHours(1); End = meetingHistory.MeetingDate.Date.AddHours(17).AddMinutes(1);
} }
} }
+5
View File
@@ -37,6 +37,11 @@ public class ChapterSettings
/// </summary> /// </summary>
public string CompetitionYear { get; set; } = "2026"; public string CompetitionYear { get; set; } = "2026";
/// <summary>
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
/// </summary>
public string StateAbbrev { get; set; } = "ST";
/// <summary> /// <summary>
/// School level for the chapter (null = import both MS and HS events) /// School level for the chapter (null = import both MS and HS events)
/// </summary> /// </summary>
@@ -0,0 +1,33 @@
namespace WebApp.Models;
/// <summary>
/// Per-student handout filters; section <see cref="SectionName"/>. Edit in Data/appsettings.json, save, refresh the page (no redeploy).
/// </summary>
public class StateScheduleHandoutOptions
{
public const string SectionName = "ChapterSettings:StateScheduleHandout";
/// <summary>
/// <see cref="Core.Entities.EventOccurrence.SpecialEventType"/> values allowed on student pages.
/// Gated in code (omit here): VotingDelegateMeeting, MeetTheCandidates, ChapterOfficerMeeting.
/// </summary>
public string[] StudentSpecialEventTypes { get; set; } =
[
"GeneralSchedule",
"SocialGathering"
];
/// <summary>
/// Occurrence <see cref="Core.Entities.EventOccurrence.Name"/> substrings that exclude a row from student pages (case-insensitive).
/// Master schedule still lists all occurrences.
/// </summary>
public string[] StudentExcludeOccurrenceNameSubstrings { get; set; } =
[
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
];
}
+13 -9
View File
@@ -11,6 +11,7 @@ using WebApp;
using WebApp.Authentication; using WebApp.Authentication;
using WebApp.Components; using WebApp.Components;
using WebApp.Logging; using WebApp.Logging;
using WebApp.Models;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -101,17 +102,16 @@ builder.Host.UseSerilog((context, configuration) =>
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger)); .WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
}); });
// Configure authentication secrets for production (Docker, etc.) // Optional user list for login (same file as production). Loaded whenever present so local Development can mirror production auth.
var authSecretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(authSecretsPath))
{
builder.Configuration.AddJsonFile(authSecretsPath, optional: false, reloadOnChange: true);
Console.WriteLine($"Loaded authentication users from {authSecretsPath}");
}
if (builder.Environment.IsProduction()) if (builder.Environment.IsProduction())
{ {
// Option 1: Load from volume-mounted secrets file in Data directory
var secretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(secretsPath))
{
builder.Configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
}
// Option 2: Environment variables with prefix
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_"); builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
} }
@@ -195,12 +195,16 @@ builder.Services.AddScoped<WebApp.Services.FormValidationService>();
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>(); builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>(); builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
builder.Services.AddScoped<WebApp.Services.ITeamMeetingHistoryService, WebApp.Services.TeamMeetingHistoryService>(); builder.Services.AddScoped<WebApp.Services.ITeamMeetingHistoryService, WebApp.Services.TeamMeetingHistoryService>();
builder.Services.AddScoped<WebApp.Services.ICalendarService, WebApp.Services.CalendarService>();
builder.Services.AddScoped<Core.Services.INoteNamingService, Core.Services.NoteNamingService>(); builder.Services.AddScoped<Core.Services.INoteNamingService, Core.Services.NoteNamingService>();
builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>(); builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
// State container for maintaining state per user connection (Blazor Server) // State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped<StateContainer>(); builder.Services.AddScoped<StateContainer>();
+144
View File
@@ -0,0 +1,144 @@
using Core.Entities;
using Core.Utility;
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Service for calendar-related operations.
/// </summary>
public class CalendarService : ICalendarService
{
private readonly IEventOccurrenceService _eventOccurrenceService;
private readonly ITeamMeetingHistoryService _teamMeetingHistoryService;
public CalendarService(
IEventOccurrenceService eventOccurrenceService,
ITeamMeetingHistoryService teamMeetingHistoryService)
{
_eventOccurrenceService = eventOccurrenceService;
_teamMeetingHistoryService = teamMeetingHistoryService;
}
public async Task<List<CalendarItemWrapper>> GetAllCalendarItemsAsync()
{
var items = new List<CalendarItemWrapper>();
await AddEventItemsAsync(items, includeStudentNames: true);
await AddMeetingItemsAsync(items);
return items;
}
public async Task<List<CalendarItemWrapper>> GetUpcomingCalendarItemsAsync(int count = 3)
{
var today = DateTime.Today;
var items = new List<CalendarItemWrapper>();
await AddEventItemsAsync(items, startDate: today, includeStudentNames: false);
await AddMeetingItemsAsync(items, startDate: today);
// Sort by date and take top N
return items
.OrderBy(item => item.Start)
.Take(count)
.ToList();
}
private async Task AddEventItemsAsync(
List<CalendarItemWrapper> items,
DateTime? startDate = null,
bool includeStudentNames = false)
{
try
{
var occurrences = await _eventOccurrenceService.GetEventOccurrencesAsync();
var eventOccurrences = occurrences as EventOccurrence[] ?? occurrences.ToArray();
// Filter by start date if provided
if (startDate.HasValue)
{
eventOccurrences = eventOccurrences
.Where(occ => occ.StartTime.Date >= startDate.Value)
.ToArray();
}
Dictionary<int, List<Team>>? teamsByEventId = null;
if (includeStudentNames)
{
// Get all unique event definition IDs that have occurrences
var eventDefinitionIds = eventOccurrences
.Where(occ => occ?.EventDefinition?.Id != null)
.Select(occ => occ!.EventDefinition!.Id)
.Distinct()
.ToList();
// Load teams for all event definitions
teamsByEventId = await _eventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
}
// Add event occurrences
foreach (var occ in eventOccurrences)
{
try
{
List<string>? studentFirstNames = null;
if (includeStudentNames && occ.EventDefinition != null && teamsByEventId != null)
{
studentFirstNames = teamsByEventId.TryGetValue(occ.EventDefinition.Id, out var teams)
? TeamStudentNameFormatter.FormatStudentListForEvent(
occ.EventDefinition,
teams,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Star,
Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical
})
: [];
}
var calendarEventItem = new CalendarEventItem(occ, studentFirstNames);
items.Add(new CalendarItemWrapper(calendarEventItem));
}
catch (Exception)
{
// Continue processing other items
}
}
}
catch (Exception)
{
// Continue - don't fail the entire calendar load if events fail
}
}
private async Task AddMeetingItemsAsync(
List<CalendarItemWrapper> items,
DateTime? startDate = null)
{
try
{
var meetingHistories = await _teamMeetingHistoryService.GetMeetingHistoriesAsync();
IEnumerable<TeamMeetingHistory> meetings = meetingHistories;
if (startDate.HasValue)
{
meetings = meetings.Where(m => m.MeetingDate.Date >= startDate.Value);
}
foreach (var meetingHistory in meetings)
{
try
{
var calendarMeetingItem = new CalendarMeetingItem(meetingHistory);
items.Add(new CalendarItemWrapper(calendarMeetingItem));
}
catch (Exception)
{
// Continue processing other items
}
}
}
catch (Exception)
{
// Continue - don't fail the entire calendar load if meetings fail
}
}
}
+4 -2
View File
@@ -38,9 +38,11 @@ public class EventDefinitionService
return; return;
} }
// Get all existing careers from database (case-insensitive lookup) // Get existing careers with tracking so we use the same instances the context
// may already be tracking (e.g. from the event's RelatedCareers). Using
// AsNoTracking() would create duplicate instances and cause "another instance
// with the same key value is already being tracked".
var existingCareers = await _context.Careers var existingCareers = await _context.Careers
.AsNoTracking()
.Where(c => !string.IsNullOrWhiteSpace(c.Name)) .Where(c => !string.IsNullOrWhiteSpace(c.Name))
.ToListAsync(); .ToListAsync();
+3 -2
View File
@@ -39,13 +39,14 @@ public class EventOccurrenceService : IEventOccurrenceService
} }
var teams = await _context.Teams var teams = await _context.Teams
.Include(t => t.Event)
.Include(t => t.Students) .Include(t => t.Students)
.Include(t => t.Captain) .Include(t => t.Captain)
.Where(t => ids.Contains(t.Event.Id)) .Where(t => t.Event != null && ids.Contains(t.Event.Id))
.ToListAsync(); .ToListAsync();
return teams return teams
.GroupBy(t => t.Event.Id) .GroupBy(t => t.Event!.Id)
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
} }
} }
+25
View File
@@ -0,0 +1,25 @@
using Core.Entities;
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Service for calendar-related operations.
/// </summary>
public interface ICalendarService
{
/// <summary>
/// Gets all calendar items (events and meetings) for display in the calendar.
/// Includes student names for events.
/// </summary>
/// <returns>A list of CalendarItemWrapper objects ready for display.</returns>
Task<List<CalendarItemWrapper>> GetAllCalendarItemsAsync();
/// <summary>
/// Gets the next N upcoming calendar items (events and meetings) starting from today.
/// Returns CalendarItemWrapper objects ready for display.
/// </summary>
/// <param name="count">The maximum number of items to return. Default is 3.</param>
/// <returns>A list of CalendarItemWrapper objects ready for display.</returns>
Task<List<CalendarItemWrapper>> GetUpcomingCalendarItemsAsync(int count = 3);
}
@@ -47,4 +47,19 @@ public interface ITeamMeetingHistoryService
/// <param name="meetingDate">The date of the meeting</param> /// <param name="meetingDate">The date of the meeting</param>
/// <returns>The note if found, null otherwise</returns> /// <returns>The note if found, null otherwise</returns>
Task<Note?> GetMeetingNoteAsync(DateTime meetingDate); Task<Note?> GetMeetingNoteAsync(DateTime meetingDate);
/// <summary>
/// Gets all meeting histories for a specific team, ordered by date descending.
/// </summary>
/// <param name="teamId">The ID of the team</param>
/// <returns>Meeting histories for the team, ordered by date descending</returns>
Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesForTeamAsync(int teamId);
/// <summary>
/// Gets the count of meeting histories for a specific team.
/// This is more efficient than loading all histories just to count them.
/// </summary>
/// <param name="teamId">The ID of the team</param>
/// <returns>The number of meeting histories for the team</returns>
Task<int> GetMeetingHistoryCountForTeamAsync(int teamId);
} }
@@ -202,4 +202,27 @@ public class TeamMeetingHistoryService : ITeamMeetingHistoryService
.Distinct() .Distinct()
.ToListAsync(); .ToListAsync();
} }
public async Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesForTeamAsync(int teamId)
{
return await _context.TeamMeetingHistories
.AsNoTracking()
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Students)
.Include(tmh => tmh.Students)
.Where(tmh => tmh.Teams.Any(t => t.Id == teamId))
.OrderByDescending(tmh => tmh.MeetingDate)
.ThenByDescending(tmh => tmh.Id)
.ToListAsync();
}
public async Task<int> GetMeetingHistoryCountForTeamAsync(int teamId)
{
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.Teams.Any(t => t.Id == teamId))
.CountAsync();
}
} }
@@ -0,0 +1,79 @@
using Core.Entities;
using WebApp.Models;
namespace WebApp.Utility;
/// <summary>
/// Determines which special <see cref="EventOccurrence"/> rows belong on a per-student handout.
/// </summary>
public static class StateScheduleOccurrenceFilter
{
public static bool NameMatchesStudentExclude(string? name, StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(name)) return false;
foreach (var sub in options.StudentExcludeOccurrenceNameSubstrings ?? [])
{
if (string.IsNullOrWhiteSpace(sub)) continue;
if (name.Contains(sub.Trim(), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// True when the occurrence name refers to Meet the Candidates (covers General Schedule lines duplicated under another type).
/// </summary>
public static bool NameLooksLikeMeetTheCandidates(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Meet the Candidate", StringComparison.OrdinalIgnoreCase);
public static bool NameLooksLikeChapterOfficerMeeting(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Chapter Officer Meeting", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Special rows have <see cref="EventOccurrence.EventDefinitionId"/> null and <see cref="EventOccurrence.SpecialEventType"/> set.
/// </summary>
public static bool IncludeSpecialOccurrenceForStudent(
EventOccurrence occurrence,
Student student,
StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(occurrence.SpecialEventType))
return false;
if (NameMatchesStudentExclude(occurrence.Name, options))
return false;
if (occurrence.SpecialEventType == "VotingDelegateMeeting")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "MeetTheCandidates")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "ChapterOfficerMeeting")
return student.OfficerRole.HasValue;
// Same event often appears once as MeetTheCandidates and again under GeneralSchedule; non-delegates should see neither.
if (!student.VotingDelegate && NameLooksLikeMeetTheCandidates(occurrence.Name))
return false;
// Chapter Officer Meeting lines under GeneralSchedule for non-officers
if (!student.OfficerRole.HasValue && NameLooksLikeChapterOfficerMeeting(occurrence.Name))
return false;
var allowed = options.StudentSpecialEventTypes ?? [];
return allowed.Contains(occurrence.SpecialEventType);
}
/// <summary>
/// Competition rows: optional name-based exclusion on student pages.
/// </summary>
public static bool IncludeCompetitionOccurrenceForStudent(EventOccurrence occurrence, StateScheduleHandoutOptions options)
{
if (occurrence.EventDefinitionId == null)
return false;
return !NameMatchesStudentExclude(occurrence.Name, options);
}
}
+16 -1
View File
@@ -16,7 +16,22 @@
"NationalId": "0000", "NationalId": "0000",
"StateId": "00000", "StateId": "00000",
"RegionalId": "00000", "RegionalId": "00000",
"CompetitionYear": "2026" "CompetitionYear": "2026",
"StateAbbrev": "ST",
"StateScheduleHandout": {
"StudentSpecialEventTypes": [
"GeneralSchedule",
"SocialGathering"
],
"StudentExcludeOccurrenceNameSubstrings": [
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
]
}
}, },
"ValidationSettings": { "ValidationSettings": {
"MinRecommendedEvents": 2, "MinRecommendedEvents": 2,
+109
View File
@@ -48,6 +48,76 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.state-schedule-table {
width: 100%;
}
.state-schedule-table th,
.state-schedule-table td,
.state-schedule-table table th,
.state-schedule-table table td {
vertical-align: top !important;
}
.combined-sub-event {
padding-left: 1.5rem !important;
}
@media print {
.state-schedule-handout {
margin-left: -30pt !important;
margin-right: -12pt !important;
padding-left: 0 !important;
padding-right: 0 !important;
width: calc(100% + 42pt) !important;
max-width: none !important;
}
.state-schedule-handout .mud-paper,
.state-schedule-handout .mud-table-container,
.state-schedule-handout .mud-table {
box-shadow: none !important;
}
.state-schedule-table,
.state-schedule-table table {
width: 100%;
border-collapse: collapse;
}
.state-schedule-table th,
.state-schedule-table td,
.state-schedule-table table th,
.state-schedule-table table td {
vertical-align: top;
padding: 4px 8px;
border-top: none !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
.state-schedule-table thead th,
.state-schedule-table table thead th {
border-bottom: 2px solid #000 !important;
}
.state-schedule-table tbody td,
.state-schedule-table table tbody td {
border-bottom: 1px solid #000 !important;
}
.state-schedule-table tbody tr:last-child td,
.state-schedule-table table tbody tr:last-child td {
border-bottom: none !important;
}
.state-schedule-table .combined-sub-event,
.state-schedule-table table .combined-sub-event {
padding-left: 1.5rem !important;
}
}
.page-header { .page-header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -266,3 +336,42 @@
.note-color-2 { .note-color-2 {
background-color: #f3e5f5; background-color: #f3e5f5;
} }
/* Calendar event item styling */
.calendar-event-item {
background-color: var(--mud-palette-primary);
color: var(--mud-palette-primary-text);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
width: 100% !important;
height: 100% !important;
display: block !important;
box-sizing: border-box;
text-align: center;
}
/* Ensure tooltip wrapper doesn't constrain width or height and adds spacing */
.event-calendar .mud-tooltip-root {
width: 100%;
height: 100%;
display: block;
padding: 2px;
box-sizing: border-box;
}
.calendar-event-item:hover {
background-color: var(--mud-palette-primary-darken);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
}
.calendar-event-item:active {
background-color: var(--mud-palette-primary-darken);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}