Compare commits
8
Commits
675f04afec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432caa0fe8 | ||
|
|
8d7c6b103c | ||
|
|
5d2d019e87 | ||
|
|
ea1bb70740 | ||
|
|
4401e4a3ec | ||
|
|
a9036d5d04 | ||
|
|
4dcd9e5aab | ||
|
|
336bbb1dec |
@@ -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">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
|
||||
</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">
|
||||
<MudTooltip Text="Admin">
|
||||
<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);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
@using WebApp.Models
|
||||
@using WebApp.Models
|
||||
|
||||
@if (EventDefinition is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@* @if (EventDefinition.LevelOfEffort.HasValue)
|
||||
{
|
||||
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
|
||||
@@ -27,12 +31,17 @@
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required EventDefinition EventDefinition { get; set; }
|
||||
public EventDefinition? EventDefinition { get; set; }
|
||||
|
||||
private string _attributes = string.Empty;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (EventDefinition is null)
|
||||
{
|
||||
_attributes = string.Empty;
|
||||
return;
|
||||
}
|
||||
_attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " ";
|
||||
_attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " ";
|
||||
_attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " ";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/events/edit"
|
||||
@page "/events/edit"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -163,9 +163,10 @@
|
||||
{
|
||||
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
|
||||
.Include(e => e.RelatedCareers)
|
||||
.FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
|
||||
|
||||
if (trackedEntity == null)
|
||||
@@ -177,6 +178,8 @@
|
||||
|
||||
// Update scalar properties from the form-bound entity
|
||||
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
|
||||
await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity);
|
||||
|
||||
@@ -87,6 +87,13 @@
|
||||
AddTooltip="Add regional event teams"
|
||||
RemoveTooltip="Remove regional event teams" />
|
||||
</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">
|
||||
<AddRemoveFilter Label="Individual"
|
||||
OnAdd="AddIndividual"
|
||||
@@ -246,6 +253,13 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void AddPresubmission()
|
||||
{
|
||||
var presubmissionTeams = _teams.Where(t => t.Event?.Presubmission == true);
|
||||
_scheduledTeams = _scheduledTeams.Concat(presubmissionTeams).Distinct();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void AddHighLevelOfEffort()
|
||||
{
|
||||
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
|
||||
@@ -266,6 +280,16 @@
|
||||
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()
|
||||
{
|
||||
var individualTeams = _teams.Where(t => t.Event.EventFormat == EventFormat.Individual);
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<MudTooltip Text="Handout">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
||||
</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")">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
|
||||
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
|
||||
|
||||
@@ -41,9 +41,8 @@ else
|
||||
@{
|
||||
var students
|
||||
= context.Students
|
||||
.OrderByDescending(s => s == context.Captain)
|
||||
.ThenBy(s => s.EventRankings.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue)
|
||||
.ThenByDescending(e => e.Grade)
|
||||
.OrderByDescending(s => context.Captain != null && context.Captain.Equals(s))
|
||||
.ThenByDescending(e => e.Grade + e.TsaYear)
|
||||
.ThenBy(e => e.FirstName)
|
||||
.ToArray();
|
||||
}
|
||||
@@ -233,6 +232,7 @@ else
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Students)
|
||||
.Include(e => e.Captain)
|
||||
.OrderByEventFormatFirst()
|
||||
.ThenBy(e => e.Event.Name)
|
||||
.ThenBy(e => e.Identifier ?? "")
|
||||
@@ -244,6 +244,8 @@ else
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Captain)
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Event)
|
||||
.Include(e => e.EventRankings)
|
||||
.ThenInclude(e => e.EventDefinition)
|
||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
||||
|
||||
@@ -37,6 +37,11 @@ public class ChapterSettings
|
||||
/// </summary>
|
||||
public string CompetitionYear { get; set; } = "2026";
|
||||
|
||||
/// <summary>
|
||||
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
|
||||
/// </summary>
|
||||
public string StateAbbrev { get; set; } = "ST";
|
||||
|
||||
/// <summary>
|
||||
/// School level for the chapter (null = import both MS and HS events)
|
||||
/// </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"
|
||||
];
|
||||
}
|
||||
+11
-8
@@ -11,6 +11,7 @@ using WebApp;
|
||||
using WebApp.Authentication;
|
||||
using WebApp.Components;
|
||||
using WebApp.Logging;
|
||||
using WebApp.Models;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -101,17 +102,16 @@ builder.Host.UseSerilog((context, configuration) =>
|
||||
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
|
||||
});
|
||||
|
||||
// Configure authentication secrets for production (Docker, etc.)
|
||||
if (builder.Environment.IsProduction())
|
||||
// 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))
|
||||
{
|
||||
// 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);
|
||||
builder.Configuration.AddJsonFile(authSecretsPath, optional: false, reloadOnChange: true);
|
||||
Console.WriteLine($"Loaded authentication users from {authSecretsPath}");
|
||||
}
|
||||
|
||||
// Option 2: Environment variables with prefix
|
||||
if (builder.Environment.IsProduction())
|
||||
{
|
||||
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.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
|
||||
|
||||
builder.Services.Configure<StateScheduleHandoutOptions>(
|
||||
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
|
||||
|
||||
// State container for maintaining state per user connection (Blazor Server)
|
||||
builder.Services.AddScoped<StateContainer>();
|
||||
|
||||
|
||||
@@ -38,9 +38,11 @@ public class EventDefinitionService
|
||||
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
|
||||
.AsNoTracking()
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
@@ -39,13 +39,14 @@ public class EventOccurrenceService : IEventOccurrenceService
|
||||
}
|
||||
|
||||
var teams = await _context.Teams
|
||||
.Include(t => t.Event)
|
||||
.Include(t => t.Students)
|
||||
.Include(t => t.Captain)
|
||||
.Where(t => ids.Contains(t.Event.Id))
|
||||
.Where(t => t.Event != null && ids.Contains(t.Event.Id))
|
||||
.ToListAsync();
|
||||
|
||||
return teams
|
||||
.GroupBy(t => t.Event.Id)
|
||||
.GroupBy(t => t.Event!.Id)
|
||||
.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",
|
||||
"StateId": "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": {
|
||||
"MinRecommendedEvents": 2,
|
||||
|
||||
@@ -48,6 +48,76 @@
|
||||
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 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user