Compare commits
6
Commits
4dcd9e5aab
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432caa0fe8 | ||
|
|
8d7c6b103c | ||
|
|
5d2d019e87 | ||
|
|
ea1bb70740 | ||
|
|
4401e4a3ec | ||
|
|
a9036d5d04 |
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,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>
|
||||||
|
|||||||
@@ -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 (Student–Team–Student) 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);
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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 ?? "")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
];
|
||||||
|
}
|
||||||
+12
-9
@@ -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_");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +202,9 @@ builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.
|
|||||||
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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user