Compare commits
35
Commits
5c4aaf91df
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432caa0fe8 | ||
|
|
8d7c6b103c | ||
|
|
5d2d019e87 | ||
|
|
ea1bb70740 | ||
|
|
4401e4a3ec | ||
|
|
a9036d5d04 | ||
|
|
4dcd9e5aab | ||
|
|
336bbb1dec | ||
|
|
675f04afec | ||
|
|
84eaf338a9 | ||
|
|
15d7edec8f | ||
|
|
46836fde2e | ||
|
|
d0ce71397b | ||
|
|
840a8edbf1 | ||
|
|
680f61241a | ||
|
|
083e81aa25 | ||
|
|
bba0f5f618 | ||
|
|
9ab241ed77 | ||
|
|
804e12ca22 | ||
|
|
cc6e0d71a7 | ||
|
|
a503655f97 | ||
|
|
6e2834f2be | ||
|
|
48861eb6a6 | ||
|
|
455be30821 | ||
|
|
ddb743847d | ||
|
|
649a0061cf | ||
|
|
6bc4c2e7f2 | ||
|
|
9ed9c93540 | ||
|
|
7679a458e0 | ||
|
|
84b31800ad | ||
|
|
b4c11cd0a6 | ||
|
|
e6eb35ee67 | ||
|
|
947d95893f | ||
|
|
8b0451c2ec | ||
|
|
5f2d7b5b31 |
@@ -0,0 +1,58 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for calculating overlaps in team scheduling solutions.
|
||||||
|
/// </summary>
|
||||||
|
public static class OverlapCalculationHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates teams with excluded students and absent students filtered out for overlap calculation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">The teams to filter</param>
|
||||||
|
/// <param name="timeSlotIndex">The time slot index for exclusion lookups</param>
|
||||||
|
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
|
||||||
|
/// <param name="absentStudentIds">Set of absent student IDs to exclude from overlap calculations</param>
|
||||||
|
/// <returns>Teams with excluded and absent students removed</returns>
|
||||||
|
public static Team[] GetTeamsWithoutExcludedStudents(
|
||||||
|
Team[] teams,
|
||||||
|
int timeSlotIndex,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
|
||||||
|
HashSet<int> absentStudentIds)
|
||||||
|
{
|
||||||
|
return teams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find excluded students for this team in this time slot
|
||||||
|
// Also exclude absent students from overlap calculations
|
||||||
|
var includedStudents = team.Students
|
||||||
|
.Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, s.Id, excludedStudents) &&
|
||||||
|
!absentStudentIds.Contains(s.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// If no students are excluded, return original team
|
||||||
|
if (includedStudents.Count == team.Students.Count)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create a temporary team with excluded and absent students removed
|
||||||
|
return new Team
|
||||||
|
{
|
||||||
|
Id = team.Id,
|
||||||
|
Event = team.Event,
|
||||||
|
Students = includedStudents,
|
||||||
|
Captain = team.Captain,
|
||||||
|
Identifier = team.Identifier
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStudentExcluded(
|
||||||
|
int teamId,
|
||||||
|
int timeSlotIndex,
|
||||||
|
int studentId,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
var key = (teamId, timeSlotIndex, studentId);
|
||||||
|
return excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Post-processing utilities for team scheduler solutions.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamSchedulerPostProcessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extends teams to adjacent time slots (both forward and backward).
|
||||||
|
/// Teams marked as extended will appear in consecutive time slots.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="solution">The scheduler solution to modify</param>
|
||||||
|
/// <param name="extendedTeams">Teams that should be extended to adjacent slots</param>
|
||||||
|
/// <param name="allStudents">All available students for overlap calculations</param>
|
||||||
|
/// <param name="getTeamsWithoutExcludedStudents">Function to filter teams for overlap calculation</param>
|
||||||
|
public static void ExtendTeamsInSolution(
|
||||||
|
TeamSchedulerSolution solution,
|
||||||
|
IEnumerable<Team> extendedTeams,
|
||||||
|
Student[] allStudents,
|
||||||
|
Func<Team[], int, Team[]> getTeamsWithoutExcludedStudents)
|
||||||
|
{
|
||||||
|
if (solution.TimeSlots == null || !solution.TimeSlots.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamsList = extendedTeams.ToList();
|
||||||
|
if (!extendedTeamsList.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamIds = extendedTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
// Find which time slot each extended team is in and extend both forward and backward
|
||||||
|
for (int slotIndex = 0; slotIndex < solution.TimeSlots.Length; slotIndex++)
|
||||||
|
{
|
||||||
|
var currentSlot = solution.TimeSlots[slotIndex];
|
||||||
|
var teamsToExtend = currentSlot.Teams.Where(t => extendedTeamIds.Contains(t.Id)).ToList();
|
||||||
|
|
||||||
|
if (!teamsToExtend.Any())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Extend forward: add to next time slot (if exists)
|
||||||
|
if (slotIndex + 1 < solution.TimeSlots.Length)
|
||||||
|
{
|
||||||
|
var nextSlot = solution.TimeSlots[slotIndex + 1];
|
||||||
|
var nextSlotTeamsList = nextSlot.Teams.ToList();
|
||||||
|
var nextSlotTeamIds = nextSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!nextSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
nextSlotTeamsList.Add(team);
|
||||||
|
nextSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSlot.Teams = nextSlotTeamsList.ToArray();
|
||||||
|
var nextSlotIndex = slotIndex + 1;
|
||||||
|
var nextSlotTeamsForOverlap = getTeamsWithoutExcludedStudents(nextSlot.Teams, nextSlotIndex);
|
||||||
|
nextSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(nextSlotTeamsForOverlap);
|
||||||
|
nextSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(nextSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend backward: add to previous time slot (if exists)
|
||||||
|
if (slotIndex > 0)
|
||||||
|
{
|
||||||
|
var previousSlot = solution.TimeSlots[slotIndex - 1];
|
||||||
|
var previousSlotTeamsList = previousSlot.Teams.ToList();
|
||||||
|
var previousSlotTeamIds = previousSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!previousSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
previousSlotTeamsList.Add(team);
|
||||||
|
previousSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSlot.Teams = previousSlotTeamsList.ToArray();
|
||||||
|
var previousSlotIndex = slotIndex - 1;
|
||||||
|
var previousSlotTeamsForOverlap = getTeamsWithoutExcludedStudents(previousSlot.Teams, previousSlotIndex);
|
||||||
|
previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlotTeamsForOverlap);
|
||||||
|
previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,5 +26,11 @@ public class Note
|
|||||||
[Display(Name = "Last Modified By")]
|
[Display(Name = "Last Modified By")]
|
||||||
public string? LastModifiedBy { get; set; }
|
public string? LastModifiedBy { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Is Pinned")]
|
||||||
|
public bool IsPinned { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Is Deleted")]
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
public List<NoteHistory> NoteHistories { get; } = [];
|
public List<NoteHistory> NoteHistories { get; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Core.Models;
|
using Core.Models;
|
||||||
|
|
||||||
namespace Core.Entities;
|
namespace Core.Entities;
|
||||||
@@ -61,6 +61,6 @@ public class Team
|
|||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
return $"{Event?.Name ?? "(no event)"} {(Identifier != null ? $"({Identifier})" : "")}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class TeamMeetingHistory
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Meeting Date")]
|
||||||
|
public DateTime MeetingDate { get; set; }
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public List<Team> Teams { get; set; } = [];
|
||||||
|
public List<Student> Students { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -12,5 +12,37 @@ public class PartialTeam : Team
|
|||||||
var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList();
|
var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList();
|
||||||
return new PartialTeam{Identifier = Identifier, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents };
|
return new PartialTeam{Identifier = Identifier, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates PartialTeam instances from teams with excluded students.
|
||||||
|
/// Aggregates exclusions across all time slots for each team.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">The teams to process</param>
|
||||||
|
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
|
||||||
|
/// <returns>Array of teams, with PartialTeam instances for teams with exclusions</returns>
|
||||||
|
public static Team[] CreatePartialTeamsFromExclusions(
|
||||||
|
IEnumerable<Team> teams,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
return teams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find all students excluded for this team across all time slots
|
||||||
|
var excludedStudentIds = excludedStudents.Keys
|
||||||
|
.Where(k => k.teamId == team.Id && excludedStudents[k])
|
||||||
|
.Select(k => k.studentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
if (excludedStudentIds.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
var excludedStudentsList = team.Students.Where(s => excludedStudentIds.Contains(s.Id)).ToList();
|
||||||
|
if (excludedStudentsList.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create PartialTeam with excluded students
|
||||||
|
return team.CloneWithOmittedStudents(excludedStudentsList);
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing note naming conventions throughout the system.
|
||||||
|
/// Centralizes the logic for generating note titles for meeting notes and page notes.
|
||||||
|
/// </summary>
|
||||||
|
public interface INoteNamingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title for a meeting note based on the meeting date.
|
||||||
|
/// Format: "#Meeting Notes MM/dd/yyyy"
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meetingDate">The date of the meeting</param>
|
||||||
|
/// <returns>The formatted meeting note title</returns>
|
||||||
|
string GetMeetingNoteTitle(DateTime meetingDate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title for a page note based on the page identifier.
|
||||||
|
/// Format: "#{pageIdentifier}"
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pageIdentifier">The page identifier (e.g., "students", "teams")</param>
|
||||||
|
/// <returns>The formatted page note title</returns>
|
||||||
|
string GetPageNoteTitle(string pageIdentifier);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a note title represents a page note (starts with "#").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noteTitle">The note title to check</param>
|
||||||
|
/// <returns>True if the note is a page note, false otherwise</returns>
|
||||||
|
bool IsPageNote(string noteTitle);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a note title represents a meeting note (starts with "#Meeting Notes").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noteTitle">The note title to check</param>
|
||||||
|
/// <returns>True if the note is a meeting note, false otherwise</returns>
|
||||||
|
bool IsMeetingNote(string noteTitle);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of INoteNamingService that provides note naming conventions.
|
||||||
|
/// Uses "#" as the prefix for page notes and meeting notes.
|
||||||
|
/// </summary>
|
||||||
|
public class NoteNamingService : INoteNamingService
|
||||||
|
{
|
||||||
|
private const string PageNotePrefix = "#";
|
||||||
|
private const string MeetingNotePrefix = "#Meeting Notes";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string GetMeetingNoteTitle(DateTime meetingDate)
|
||||||
|
{
|
||||||
|
return $"{MeetingNotePrefix} {meetingDate:MM/dd/yyyy}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string GetPageNoteTitle(string pageIdentifier)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pageIdentifier))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Page identifier cannot be null or empty", nameof(pageIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{PageNotePrefix}{pageIdentifier}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsPageNote(string noteTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(noteTitle))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteTitle.StartsWith(PageNotePrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsMeetingNote(string noteTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(noteTitle))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using FuzzySharp;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for matching team names from clipboard text using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamClipboardMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Matches team names from clipboard text against available teams using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clipboardText">The text content from the clipboard.</param>
|
||||||
|
/// <param name="availableTeams">The collection of available teams to match against.</param>
|
||||||
|
/// <param name="matchThreshold">The minimum fuzzy match score threshold (0-100). Default is 85.</param>
|
||||||
|
/// <returns>A list of teams that match the clipboard text, ordered by match quality.</returns>
|
||||||
|
public static List<Team> MatchTeamsFromClipboard(
|
||||||
|
string clipboardText,
|
||||||
|
IEnumerable<Team> availableTeams,
|
||||||
|
int matchThreshold = 85)
|
||||||
|
{
|
||||||
|
var matchedTeams = new List<Team>();
|
||||||
|
var teamsList = availableTeams.ToList();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clipboardText) || !teamsList.Any())
|
||||||
|
{
|
||||||
|
return matchedTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split clipboard text by newlines
|
||||||
|
var lines = clipboardText.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
// Extract team name portion (before " - " if present, as clipboard format includes student lists)
|
||||||
|
var teamName = ExtractTeamNameFromLine(line);
|
||||||
|
|
||||||
|
// Skip empty lines or lines that look like headers/metadata
|
||||||
|
if (ShouldSkipLine(teamName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best match using fuzzy matching
|
||||||
|
var matchedTeam = FindBestMatch(teamName, teamsList, matchThreshold);
|
||||||
|
|
||||||
|
if (matchedTeam != null && !matchedTeams.Any(t => t.Id == matchedTeam.Id))
|
||||||
|
{
|
||||||
|
matchedTeams.Add(matchedTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTeamNameFromLine(string line)
|
||||||
|
{
|
||||||
|
var dashIndex = line.IndexOf(" - ");
|
||||||
|
if (dashIndex > 0)
|
||||||
|
{
|
||||||
|
return line.Substring(0, dashIndex).Trim();
|
||||||
|
}
|
||||||
|
return line.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldSkipLine(string teamName)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(teamName) ||
|
||||||
|
teamName.StartsWith("--") ||
|
||||||
|
teamName.Equals("Unscheduled", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Team? FindBestMatch(string teamName, List<Team> availableTeams, int matchThreshold)
|
||||||
|
{
|
||||||
|
// Normalize both strings to lowercase for case-insensitive comparison
|
||||||
|
var normalizedTeamName = teamName.ToLowerInvariant();
|
||||||
|
|
||||||
|
var bestMatch = availableTeams
|
||||||
|
.Select(team => new
|
||||||
|
{
|
||||||
|
Team = team,
|
||||||
|
Score = Fuzz.Ratio(normalizedTeamName, team.ToString().ToLowerInvariant())
|
||||||
|
})
|
||||||
|
.Where(x => x.Score >= matchThreshold)
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return bestMatch?.Team;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for filtering teams based on various criteria.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamFilterExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds teams that are regional events to the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> AddRegionals(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
return allTeams.Where(e => e.Event.RegionalEvent).Concat(currentTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds teams with high level of effort (>= 3) to the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> AddHighLevelOfEffort(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
return allTeams.Where(e => e.Event.LevelOfEffort >= 3).Concat(currentTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes individual event teams from the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> RemoveIndividual(this IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
return teams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes teams with low level of effort (<= 1) from the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> RemoveLowLevelOfEffort(this IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
return teams.Where(t => t.Event.LevelOfEffort > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inverts the selection - returns all teams not in the current selection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> Invert(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
var currentTeamIds = currentTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
return allTeams.Where(t => !currentTeamIds.Contains(t.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ namespace Data
|
|||||||
public DbSet<Career> Careers { get; set; }
|
public DbSet<Career> Careers { get; set; }
|
||||||
public DbSet<Note> Notes { get; set; }
|
public DbSet<Note> Notes { get; set; }
|
||||||
public DbSet<NoteHistory> NoteHistories { get; set; }
|
public DbSet<NoteHistory> NoteHistories { get; set; }
|
||||||
|
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
|
||||||
|
|
||||||
public AppDbContext()
|
public AppDbContext()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ namespace Data.Configurations
|
|||||||
// Indexes
|
// Indexes
|
||||||
builder.HasIndex(n => n.Title);
|
builder.HasIndex(n => n.Title);
|
||||||
builder.HasIndex(n => n.CreatedAt);
|
builder.HasIndex(n => n.CreatedAt);
|
||||||
|
builder.HasIndex(n => n.IsPinned);
|
||||||
|
builder.HasIndex(n => n.IsDeleted);
|
||||||
|
|
||||||
// Constraints
|
// Constraints
|
||||||
builder.Property(n => n.Title)
|
builder.Property(n => n.Title)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class TeamMeetingHistoryConfiguration : IEntityTypeConfiguration<TeamMeetingHistory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TeamMeetingHistory> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(tmh => tmh.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(tmh => tmh.MeetingDate);
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(tmh => tmh.MeetingDate)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.HasMany(tmh => tmh.Teams)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity(j => j.ToTable("TeamMeetingHistoryTeams"));
|
||||||
|
|
||||||
|
builder.HasMany(tmh => tmh.Students)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity(j => j.ToTable("TeamMeetingHistoryStudents"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace Data.Migrations
|
namespace Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(AppDbContext))]
|
[DbContext(typeof(AppDbContext))]
|
||||||
[Migration("20260115185640_AddNotesAndNoteHistory")]
|
[Migration("20260116235231_AddNoteIsPinnedAndIsDeleted")]
|
||||||
partial class AddNotesAndNoteHistory
|
partial class AddNoteIsPinnedAndIsDeleted
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -196,6 +196,12 @@ namespace Data.Migrations
|
|||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("LastModifiedBy")
|
b.Property<string>("LastModifiedBy")
|
||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -212,6 +218,10 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
b.HasIndex("Title");
|
b.HasIndex("Title");
|
||||||
|
|
||||||
b.ToTable("Notes");
|
b.ToTable("Notes");
|
||||||
+14
-2
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||||||
namespace Data.Migrations
|
namespace Data.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddNotesAndNoteHistory : Migration
|
public partial class AddNoteIsPinnedAndIsDeleted : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
@@ -22,7 +22,9 @@ namespace Data.Migrations
|
|||||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true)
|
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
|
IsPinned = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -73,6 +75,16 @@ namespace Data.Migrations
|
|||||||
table: "Notes",
|
table: "Notes",
|
||||||
column: "CreatedAt");
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_IsDeleted",
|
||||||
|
table: "Notes",
|
||||||
|
column: "IsDeleted");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_IsPinned",
|
||||||
|
table: "Notes",
|
||||||
|
column: "IsPinned");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Notes_Title",
|
name: "IX_Notes_Title",
|
||||||
table: "Notes",
|
table: "Notes",
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260120024048_AddTeamMeetingHistory")]
|
||||||
|
partial class AddTeamMeetingHistory
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelatedCareersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("EventDefinitionId", "RelatedCareersId");
|
||||||
|
|
||||||
|
b.HasIndex("RelatedCareersId");
|
||||||
|
|
||||||
|
b.ToTable("EventDefinitionCareers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Career", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Careers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
|
b.HasIndex("Title");
|
||||||
|
|
||||||
|
b.ToTable("Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChangeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ModifiedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("NoteId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModifiedAt");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId", "ModifiedAt");
|
||||||
|
|
||||||
|
b.ToTable("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("MeetingDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MeetingDate");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("TeamMeetingHistoryId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryTeams", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Career", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RelatedCareersId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Note", "Note")
|
||||||
|
.WithMany("NoteHistories")
|
||||||
|
.HasForeignKey("NoteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTeamMeetingHistory : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
MeetingDate = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistories", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistoryStudents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
StudentsId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistoryStudents", x => new { x.StudentsId, x.TeamMeetingHistoryId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryStudents_Students_StudentsId",
|
||||||
|
column: x => x.StudentsId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryStudents_TeamMeetingHistories_TeamMeetingHistoryId",
|
||||||
|
column: x => x.TeamMeetingHistoryId,
|
||||||
|
principalTable: "TeamMeetingHistories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistoryTeams",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TeamsId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistoryTeams", x => new { x.TeamMeetingHistoryId, x.TeamsId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryTeams_TeamMeetingHistories_TeamMeetingHistoryId",
|
||||||
|
column: x => x.TeamMeetingHistoryId,
|
||||||
|
principalTable: "TeamMeetingHistories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryTeams_Teams_TeamsId",
|
||||||
|
column: x => x.TeamsId,
|
||||||
|
principalTable: "Teams",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistories_MeetingDate",
|
||||||
|
table: "TeamMeetingHistories",
|
||||||
|
column: "MeetingDate");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistoryStudents_TeamMeetingHistoryId",
|
||||||
|
table: "TeamMeetingHistoryStudents",
|
||||||
|
column: "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistoryTeams_TeamsId",
|
||||||
|
table: "TeamMeetingHistoryTeams",
|
||||||
|
column: "TeamsId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistoryStudents");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistoryTeams");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,6 +193,12 @@ namespace Data.Migrations
|
|||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("LastModifiedBy")
|
b.Property<string>("LastModifiedBy")
|
||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -209,6 +215,10 @@ namespace Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CreatedAt");
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
b.HasIndex("Title");
|
b.HasIndex("Title");
|
||||||
|
|
||||||
b.ToTable("Notes");
|
b.ToTable("Notes");
|
||||||
@@ -360,6 +370,22 @@ namespace Data.Migrations
|
|||||||
b.ToTable("Teams");
|
b.ToTable("Teams");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("MeetingDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MeetingDate");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("StudentTeam", b =>
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("StudentsId")
|
b.Property<int>("StudentsId")
|
||||||
@@ -375,6 +401,36 @@ namespace Data.Migrations
|
|||||||
b.ToTable("TeamStudents", (string)null);
|
b.ToTable("TeamStudents", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("TeamMeetingHistoryId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryTeams", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CareerEventDefinition", b =>
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Core.Entities.EventDefinition", null)
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
@@ -463,6 +519,36 @@ namespace Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.Note", b =>
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("NoteHistories");
|
b.Navigation("NoteHistories");
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Utility;
|
||||||
|
using Tests.Builders;
|
||||||
|
|
||||||
|
namespace Tests.Utility;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class TeamClipboardMatcher_Tests
|
||||||
|
{
|
||||||
|
private List<Team> _availableTeams = [];
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
|
||||||
|
// Create test teams
|
||||||
|
var construction = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build();
|
||||||
|
var flight = EventDefinitionBuilder.Individual("Flight").Build();
|
||||||
|
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).Build();
|
||||||
|
var coding = EventDefinitionBuilder.Individual("Coding").Build();
|
||||||
|
var medicalTech = EventDefinitionBuilder.Team("Medical Technology", 2, 3).Build();
|
||||||
|
|
||||||
|
_availableTeams =
|
||||||
|
[
|
||||||
|
TeamBuilder.Create(construction).WithIdentifier("2").Build(),
|
||||||
|
TeamBuilder.Create(flight).Build(),
|
||||||
|
TeamBuilder.Create(robotics).Build(),
|
||||||
|
TeamBuilder.Create(coding).Build(),
|
||||||
|
TeamBuilder.Create(medicalTech).Build()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ExactMatch_ReturnsMatchingTeam()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construction Challenge (2)";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
Assert.That(result[0].Identifier, Is.EqualTo("2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ExactMatchWithoutIdentifier_ReturnsMatchingTeam()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Flight"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ClipboardFormatWithStudentList_ExtractsTeamName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construction Challenge (2) - John Doe, Jane Smith";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_MultipleTeams_ReturnsAllMatches()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\nRobotics\r\nCoding";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(3));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Coding"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_FuzzyMatch_ReturnsBestMatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construcion Challange"; // Intentional typos
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_CaseInsensitive_MatchesCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "FLIGHT\r\nconstruction challenge (2)";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_SkipsEmptyLines_IgnoresEmptyEntries()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\n\r\nRobotics\r\n \r\nCoding";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_SkipsMetadataHeaders_IgnoresSpecialMarkers()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "--Unscheduled\r\nFlight\r\n--Another Header\r\nRobotics\r\nUnscheduled";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_NoMatches_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "NonExistent Team Name";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_BelowThreshold_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "XYZ"; // Very different from any team name
|
||||||
|
var highThreshold = 95; // Very high threshold
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, highThreshold);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_CustomThreshold_RespectsThreshold()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construction Challeng"; // Close match, should work with lower threshold
|
||||||
|
var lowThreshold = 70;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, lowThreshold);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_EmptyClipboard_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_NullClipboard_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string? clipboardText = null;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText!, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_EmptyTeamsList_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight";
|
||||||
|
var emptyTeams = new List<Team>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, emptyTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_DuplicateTeamNames_ReturnsSingleInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\nFlight\r\nFlight";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Flight"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_MultipleLinesWithDifferentFormats_HandlesAllFormats()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\nRobotics - Student List\r\nMedical Technology (1) - More Students";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(3));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Medical Technology"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_WhitespaceAroundTeamName_TrimsCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = " Flight \r\n Robotics ";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ReturnsTeamsFromInputCollection_MaintainsReferenceEquality()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight";
|
||||||
|
var inputTeam = _availableTeams.First(t => t.Event.Name == "Flight");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result[0], Is.SameAs(inputTeam));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_BestMatchSelected_ReturnsHighestScoringMatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Create teams with similar names
|
||||||
|
var construction1 = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build();
|
||||||
|
var construction2 = EventDefinitionBuilder.Team("Construction Challenge Advanced", 2, 4).Build();
|
||||||
|
var teams = new[]
|
||||||
|
{
|
||||||
|
TeamBuilder.Create(construction1).Build(),
|
||||||
|
TeamBuilder.Create(construction2).Build()
|
||||||
|
};
|
||||||
|
var clipboardText = "Construction Challenge"; // Should match the first one exactly
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, teams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
// The exact match should be selected (higher score)
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
||||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||||
|
<script src="js/markdownTablePaste.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,24 @@
|
|||||||
@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 ILogger<Index> Logger
|
@inject ILogger<Index> Logger
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
<PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar">
|
<PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
|
<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">
|
<AuthorizeView Roles="@AuthRoles.Administrator">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
|
<MudTooltip Text="Admin">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
<PageNoteButton PageIdentifier="Event Calendar" />
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -28,42 +35,33 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudStack Spacing="2">
|
<MudStack Spacing="2">
|
||||||
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small">
|
|
||||||
<MudButton Color="@(_currentView == CalendarView.Month ? Color.Primary : Color.Default)"
|
|
||||||
OnClick="() => { _currentView = CalendarView.Month; StateHasChanged(); }">
|
|
||||||
Month
|
|
||||||
</MudButton>
|
|
||||||
<MudButton Color="@(_currentView == CalendarView.Day ? Color.Primary : Color.Default)"
|
|
||||||
OnClick="() => { _currentView = CalendarView.Day; StateHasChanged(); }">
|
|
||||||
Day
|
|
||||||
</MudButton>
|
|
||||||
</MudButtonGroup>
|
|
||||||
|
|
||||||
<MudCalendar T="CalendarEventItem"
|
<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,12 +69,21 @@
|
|||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<CalendarEventItem>? _calendarItems;
|
private List<CalendarItemWrapper>? _calendarItems;
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,60 +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<CalendarEventItem> items = [];
|
|
||||||
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 calendarItem = new CalendarEventItem(occ, studentFirstNames);
|
|
||||||
items.Add(calendarItem);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error creating CalendarEventItem for occurrence Id={Id}, Name={Name}",
|
|
||||||
occ?.Id, occ?.Name);
|
|
||||||
// Continue processing other items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_calendarItems = items;
|
|
||||||
Logger.LogInformation("Created {Count} calendar items from {OccurrenceCount} occurrences",
|
|
||||||
_calendarItems.Count, eventOccurrences.Count());
|
|
||||||
|
|
||||||
// Find the next date with events
|
|
||||||
_calendarDate = GetNextDateWithEvents();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -152,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 nextEvent = _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 (nextEvent != null)
|
|
||||||
{
|
|
||||||
return nextEvent.Start.Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to first event if no future events
|
|
||||||
var firstEvent = _calendarItems
|
|
||||||
.OrderBy(item => item.Start)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
return firstEvent != null ? firstEvent.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)
|
||||||
@@ -230,12 +153,27 @@
|
|||||||
return string.Join("\n", parts);
|
return string.Join("\n", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnItemClicked(CalendarEventItem calendarEventItem)
|
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)
|
||||||
{
|
{
|
||||||
if (_calendarItems == null)
|
if (_calendarItems == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await ShowEventDetails(calendarEventItem);
|
if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
|
||||||
|
{
|
||||||
|
await ShowEventDetails(wrapper.EventItem);
|
||||||
|
}
|
||||||
|
else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
|
||||||
|
{
|
||||||
|
await ShowMeetingDetails(wrapper.MeetingItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ShowEventDetails(CalendarEventItem item)
|
private async Task ShowEventDetails(CalendarEventItem item)
|
||||||
@@ -259,5 +197,25 @@
|
|||||||
|
|
||||||
await DialogService.ShowAsync<EventOccurrenceDetailsDialog>("Event Details", parameters, options);
|
await DialogService.ShowAsync<EventOccurrenceDetailsDialog>("Event Details", parameters, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ShowMeetingDetails(CalendarMeetingItem item)
|
||||||
|
{
|
||||||
|
if (item.MeetingHistoryData == null) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = item.MeetingHistoryData.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
CloseOnEscapeKey = true,
|
||||||
|
CloseButton = true,
|
||||||
|
MaxWidth = MaxWidth.Large,
|
||||||
|
FullWidth = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
@* @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 : " ";
|
||||||
|
|||||||
@@ -19,10 +19,14 @@
|
|||||||
BackButtonUrl="@(ReturnUrl ?? "/events")">
|
BackButtonUrl="@(ReturnUrl ?? "/events")">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<div class="no-print">
|
<div class="no-print">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
<MudTooltip Text="Print">
|
||||||
OnClick="PrintPage"
|
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||||
Variant="Variant.Outlined">Print</MudButton>
|
OnClick="PrintPage"
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
|
Variant="Variant.Outlined">Print</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Edit">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</div>
|
</div>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -10,9 +10,15 @@
|
|||||||
|
|
||||||
<PageHeader Title="Events">
|
<PageHeader Title="Events">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
<MudTooltip Text="Create New">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton>
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Printable Descriptions">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Career Mapping">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
@page "/meeting-schedule/history"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Core.Entities
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Services
|
||||||
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||||||
|
@inject AppDbContext Context
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject IMeetingScheduleDataService DataService
|
||||||
|
@inject IMeetingScheduleStateService StateService
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<PageHeader Title="Team Meeting Schedule History">
|
||||||
|
<ActionButtons>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Href="/meeting-schedule">
|
||||||
|
Back to Schedule
|
||||||
|
</MudButton>
|
||||||
|
</ActionButtons>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
@if (_meetingHistories.Any())
|
||||||
|
{
|
||||||
|
<MudPaper Elevation="1" Class="pa-3" Style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||||
|
<table class="history-grid-table" style="min-width: max-content; width: 100%; border-collapse: collapse;">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 72px;" />
|
||||||
|
<col style="width: 40px;" />
|
||||||
|
@for (var c = 0; c < _allTeams.Count; c++)
|
||||||
|
{
|
||||||
|
<col style="width: 28px;" />
|
||||||
|
}
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="history-cell history-cell-date">
|
||||||
|
<span class="mud-typography mud-typography-caption">Date</span>
|
||||||
|
</th>
|
||||||
|
<th class="history-cell history-cell-actions">
|
||||||
|
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">Actions</span>
|
||||||
|
</th>
|
||||||
|
@{
|
||||||
|
var teamIndex = 0;
|
||||||
|
}
|
||||||
|
@foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
var isEven = teamIndex % 2 == 0;
|
||||||
|
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
|
||||||
|
<th class="@colClass">
|
||||||
|
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">@team.ToString()</span>
|
||||||
|
</th>
|
||||||
|
teamIndex++;
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="history-cell history-cell-date history-cell-times-met">Times Met</th>
|
||||||
|
<th class="history-cell history-cell-actions "></th>
|
||||||
|
@{
|
||||||
|
var summaryTeamIndex = 0;
|
||||||
|
}
|
||||||
|
@foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
var timesMet = GetTimesMetForTeam(team);
|
||||||
|
var isEven = summaryTeamIndex % 2 == 0;
|
||||||
|
var colClass = isEven ? "history-cell history-cell-col-even history-cell-times-met" : "history-cell history-cell-col-odd history-cell-times-met";
|
||||||
|
<th class="@colClass">@timesMet</th>
|
||||||
|
summaryTeamIndex++;
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@{
|
||||||
|
var rowIndex = 0;
|
||||||
|
}
|
||||||
|
@foreach (var history in _meetingHistories)
|
||||||
|
{
|
||||||
|
var rowTeamIndex = 0;
|
||||||
|
var rowClass = rowIndex % 2 == 0 ? "history-row-even" : "history-row-odd";
|
||||||
|
<tr class="@rowClass">
|
||||||
|
<td class="history-cell history-cell-date ">
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Small"
|
||||||
|
OnClick="@(() => ViewMeetingDetails(history))"
|
||||||
|
Style="text-transform: none; padding: 0 2px; min-width: unset;">
|
||||||
|
@history.MeetingDate.ToString("MM/dd/yy")
|
||||||
|
</MudButton>
|
||||||
|
</td>
|
||||||
|
<td class="history-cell history-cell-actions">
|
||||||
|
<MudTooltip Text="Load into Planner">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Upload"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
OnClick="@(() => LoadMeetingIntoPlanner(history))"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="padding: 2px;" />
|
||||||
|
</MudTooltip>
|
||||||
|
</td>
|
||||||
|
@foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
var met = TeamMetOnDate(history, team);
|
||||||
|
var isEven = rowTeamIndex % 2 == 0;
|
||||||
|
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
|
||||||
|
<td class="@colClass">
|
||||||
|
@if (met)
|
||||||
|
{
|
||||||
|
<span class="mud-typography mud-typography-body1" style="font-weight: bold;">×</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
rowTeamIndex++;
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
rowIndex++;
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">No meeting history found. Save a meeting schedule to get started.</MudAlert>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.history-grid-table {
|
||||||
|
--history-border: 1px solid var(--mud-palette-divider);
|
||||||
|
--history-shade: var(--mud-palette-background-grey);
|
||||||
|
}
|
||||||
|
.history-grid-table th,
|
||||||
|
.history-grid-table td {
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-right: var(--history-border);
|
||||||
|
border-bottom: var(--history-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.history-grid-table thead th {
|
||||||
|
border-top: var(--history-border);
|
||||||
|
background-color: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
.history-grid-table thead .history-cell-col-odd {
|
||||||
|
background-color: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
.history-grid-table thead th:first-child,
|
||||||
|
.history-grid-table tbody td:first-child {
|
||||||
|
border-left: var(--history-border);
|
||||||
|
}
|
||||||
|
.history-grid-table .history-cell-date {
|
||||||
|
padding: 2px 4px;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
.history-grid-table .history-cell-actions {
|
||||||
|
padding: 2px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
.history-grid-table thead .history-cell-col-even {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table .history-cell-times-met {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table tbody .history-row-odd td {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table tbody .history-row-even td.history-cell-col-even,
|
||||||
|
.history-grid-table tbody .history-row-odd td.history-cell-col-even {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table tbody .history-row-odd td.history-cell-col-odd {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<TeamMeetingHistory> _meetingHistories = [];
|
||||||
|
private List<Team> _allTeams = [];
|
||||||
|
private Dictionary<int, int> _timesMetDict = new();
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// Load all teams for columns
|
||||||
|
_allTeams = await Context.Teams
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Event)
|
||||||
|
.OrderBy(t => t.Event.Name)
|
||||||
|
.ThenBy(t => t.Identifier)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Load meeting histories
|
||||||
|
await RefreshMeetingHistories();
|
||||||
|
|
||||||
|
// Calculate times met
|
||||||
|
CalculateTimesMet();
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshMeetingHistories()
|
||||||
|
{
|
||||||
|
_meetingHistories = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync()).ToList();
|
||||||
|
|
||||||
|
// Extract all unique teams from meeting histories and merge with all teams
|
||||||
|
var teamsInHistory = _meetingHistories
|
||||||
|
.SelectMany(mh => mh.Teams)
|
||||||
|
.DistinctBy(t => t.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Merge with all teams, ensuring all teams are included
|
||||||
|
var allTeamIds = _allTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
var newTeams = teamsInHistory.Where(t => !allTeamIds.Contains(t.Id)).ToList();
|
||||||
|
_allTeams = _allTeams.Concat(newTeams).OrderBy(t => t.Event?.Name ?? "").ThenBy(t => t.Identifier).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CalculateTimesMet()
|
||||||
|
{
|
||||||
|
_timesMetDict.Clear();
|
||||||
|
|
||||||
|
foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
_timesMetDict[team.Id] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var history in _meetingHistories)
|
||||||
|
{
|
||||||
|
var teamIds = history.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
foreach (var teamId in teamIds)
|
||||||
|
{
|
||||||
|
if (_timesMetDict.ContainsKey(teamId))
|
||||||
|
{
|
||||||
|
_timesMetDict[teamId]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetTimesMetForTeam(Team team)
|
||||||
|
{
|
||||||
|
return _timesMetDict.GetValueOrDefault(team.Id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TeamMetOnDate(TeamMeetingHistory history, Team team)
|
||||||
|
{
|
||||||
|
return history.Teams.Any(t => t.Id == team.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ViewMeetingDetails(TeamMeetingHistory history)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = history.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Large,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (!result.Canceled)
|
||||||
|
{
|
||||||
|
// Refresh data if meeting was updated or deleted
|
||||||
|
await RefreshMeetingHistories();
|
||||||
|
CalculateTimesMet();
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMeetingIntoPlanner(TeamMeetingHistory history)
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all teams and students from database
|
||||||
|
var allTeams = await DataService.LoadTeamsAsync();
|
||||||
|
var allStudents = await DataService.LoadStudentsAsync();
|
||||||
|
|
||||||
|
// Match teams from history to all teams by ID for reference equality
|
||||||
|
var historyTeamIds = history.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
var scheduledTeams = allTeams.Where(t => historyTeamIds.Contains(t.Id));
|
||||||
|
|
||||||
|
// Calculate absent students (all students not in the meeting history's student list)
|
||||||
|
var presentStudentIds = history.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
var absentStudents = allStudents.Where(s => !presentStudentIds.Contains(s.Id));
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
await StateService.SaveScheduledTeamsAsync(scheduledTeams);
|
||||||
|
await StateService.SaveAbsentStudentsAsync(absentStudents);
|
||||||
|
// Clear extended teams and excluded students when loading from history
|
||||||
|
await StateService.SaveExtendedTeamsAsync([]);
|
||||||
|
await StateService.SaveExcludedStudentsAsync(new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>());
|
||||||
|
|
||||||
|
// Navigate to planner
|
||||||
|
NavigationManager.NavigateTo("/meeting-schedule");
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Loaded meeting from {history.MeetingDate:MM/dd/yyyy} into planner", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting into planner: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,45 @@
|
|||||||
@page "/meeting-schedule"
|
@page "/meeting-schedule"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using System.Text
|
@using System.Text
|
||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
|
@using Core.Models
|
||||||
|
@using Core.Utility
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@using Core.Utility
|
@using WebApp.Components.Features.MeetingSchedule
|
||||||
|
@using WebApp.Models
|
||||||
|
@using WebApp.Services
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
|
||||||
@inject ClipboardService ClipboardService
|
@inject ClipboardService ClipboardService
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IMeetingScheduleStateService StateService
|
||||||
|
@inject IMeetingScheduleDataService DataService
|
||||||
|
@inject IMeetingScheduleClipboardService ClipboardFormatService
|
||||||
@inject LocalStorageService LocalStorage
|
@inject LocalStorageService LocalStorage
|
||||||
|
|
||||||
<PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")" />
|
<PageHeader Title="@($"Meeting Scheduler Planner")">
|
||||||
|
<ActionButtons>
|
||||||
|
<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>
|
||||||
|
<MudTooltip Text="Save current schedule as meeting history">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="OpenSaveHistoryDialog"
|
||||||
|
Disabled="@(!_scheduledTeams.Any())">
|
||||||
|
Save to History
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<PageNoteButton PageIdentifier="Meeting Schedule" />
|
||||||
|
</ActionButtons>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
@@ -18,46 +47,93 @@
|
|||||||
<MudText Typo="Typo.h4">Time Slots</MudText>
|
<MudText Typo="Typo.h4">Time Slots</MudText>
|
||||||
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="6" sm="3" lg="2">
|
|
||||||
<MudNumericField Value="_parameters.TimeSlots"
|
|
||||||
ValueChanged="async (int val) => await OnTimeSlotCountChanged(val)"
|
|
||||||
Label="Time Slots" Min="1" Max="4">
|
|
||||||
</MudNumericField>
|
|
||||||
</MudItem>
|
|
||||||
<MudFlexBreak/>
|
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
|
<MudPaper Elevation="0" Class="pa-1" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
</MudTooltip>
|
<MudText Typo="Typo.body2">Time Slots</MudText>
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Remove"
|
||||||
|
OnClick="DecrementTimeSlots"
|
||||||
|
Disabled="@(_parameters.TimeSlots <= 1)"
|
||||||
|
Size="Size.Small"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="border-radius: var(--mud-default-borderradius) 0 0 var(--mud-default-borderradius);" />
|
||||||
|
<MudButton Disabled="true"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="min-width: 50px; width: 50px; pointer-events: none; border-left: 1px solid var(--mud-palette-lines-default); border-right: 1px solid var(--mud-palette-lines-default); border-radius: 0;">
|
||||||
|
@_parameters.TimeSlots
|
||||||
|
</MudButton>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Add"
|
||||||
|
OnClick="IncrementTimeSlots"
|
||||||
|
Disabled="@(_parameters.TimeSlots >= 4)"
|
||||||
|
Size="Size.Small"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="border-radius: 0 var(--mud-default-borderradius) var(--mud-default-borderradius) 0;" />
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
|
<AddRemoveFilter Label="High Effort"
|
||||||
|
OnAdd="AddHighLevelOfEffort"
|
||||||
|
OnRemove="RemoveHighLevelOfEffort"
|
||||||
|
AddTooltip="Schedule teams with Level of Effort >= 3"
|
||||||
|
RemoveTooltip="Remove teams with Level of Effort >= 3" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
|
<AddRemoveFilter Label="Regionals"
|
||||||
|
OnAdd="AddRegionals"
|
||||||
|
OnRemove="RemoveRegionals"
|
||||||
|
AddTooltip="Add regional event teams"
|
||||||
|
RemoveTooltip="Remove regional event teams" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
|
<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"
|
||||||
|
OnRemove="RemoveIndividual"
|
||||||
|
AddTooltip="Add individual event teams"
|
||||||
|
RemoveTooltip="Remove individual event teams" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<AddRemoveFilter Label="Low Effort"
|
||||||
|
OnAdd="AddLowLevelOfEffort"
|
||||||
|
OnRemove="RemoveLowLevelOfEffort"
|
||||||
|
AddTooltip="Add teams with Level of Effort <= 1"
|
||||||
|
RemoveTooltip="Remove teams with Level of Effort <= 1" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudTooltip Text="Load teams from clipboard text by matching team names">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="LoadTeamsFromClipboard" FullWidth="true" Disabled="@_isLoadingClipboard">Load from Clipboard</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
Class="ma-3"
|
<MudSpacer />
|
||||||
OnClick="Solve"
|
<MudTooltip Text="Copy to Clipboard">
|
||||||
Color="Color.Primary"
|
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||||
Disabled="@_isSolving">
|
</MudTooltip>
|
||||||
Solve
|
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
||||||
</MudButton>
|
OnClick="Solve"
|
||||||
<MudTooltip Text="Copy to Clipboard">
|
Color="Color.Primary"
|
||||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
Disabled="@_isSolving">
|
||||||
</MudTooltip>
|
Solve
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@@ -120,6 +196,7 @@
|
|||||||
private TeamSchedulerSolution _solution = null!;
|
private TeamSchedulerSolution _solution = null!;
|
||||||
private TeamSchedulerOptions _parameters = null!;
|
private TeamSchedulerOptions _parameters = null!;
|
||||||
bool _isSolving;
|
bool _isSolving;
|
||||||
|
private bool _isLoadingClipboard = false;
|
||||||
private IEnumerable<Team> _scheduledTeams = [];
|
private IEnumerable<Team> _scheduledTeams = [];
|
||||||
private IEnumerable<Student> _absentStudents = [];
|
private IEnumerable<Student> _absentStudents = [];
|
||||||
private IEnumerable<Team> _possibleAdditions = [];
|
private IEnumerable<Team> _possibleAdditions = [];
|
||||||
@@ -148,6 +225,22 @@
|
|||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task IncrementTimeSlots()
|
||||||
|
{
|
||||||
|
if (_parameters.TimeSlots < 4)
|
||||||
|
{
|
||||||
|
await OnTimeSlotCountChanged(_parameters.TimeSlots + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DecrementTimeSlots()
|
||||||
|
{
|
||||||
|
if (_parameters.TimeSlots > 1)
|
||||||
|
{
|
||||||
|
await OnTimeSlotCountChanged(_parameters.TimeSlots - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
|
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
|
||||||
{
|
{
|
||||||
_extendedTeams = teams;
|
_extendedTeams = teams;
|
||||||
@@ -156,37 +249,76 @@
|
|||||||
|
|
||||||
private void AddRegionals()
|
private void AddRegionals()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams = _scheduledTeams.AddRegionals(_teams);
|
||||||
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPresubmission()
|
||||||
|
{
|
||||||
|
var presubmissionTeams = _teams.Where(t => t.Event?.Presubmission == true);
|
||||||
|
_scheduledTeams = _scheduledTeams.Concat(presubmissionTeams).Distinct();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddHighLevelOfEffort()
|
private void AddHighLevelOfEffort()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
|
||||||
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveHighLevelOfEffort()
|
||||||
|
{
|
||||||
|
var highEffortTeamIds = _teams.Where(t => t.Event.LevelOfEffort >= 3).Select(t => t.Id).ToHashSet();
|
||||||
|
_scheduledTeams = _scheduledTeams.Where(t => !highEffortTeamIds.Contains(t.Id));
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveRegionals()
|
||||||
|
{
|
||||||
|
var regionalTeamIds = _teams.Where(t => t.Event.RegionalEvent).Select(t => t.Id).ToHashSet();
|
||||||
|
_scheduledTeams = _scheduledTeams.Where(t => !regionalTeamIds.Contains(t.Id));
|
||||||
|
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);
|
||||||
|
_scheduledTeams = _scheduledTeams.Concat(individualTeams).Distinct();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveIndividual()
|
private void RemoveIndividual()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams = _scheduledTeams.RemoveIndividual();
|
||||||
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLowLevelOfEffort()
|
||||||
|
{
|
||||||
|
var lowEffortTeams = _teams.Where(t => t.Event.LevelOfEffort <= 1);
|
||||||
|
_scheduledTeams = _scheduledTeams.Concat(lowEffortTeams).Distinct();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveLowLevelOfEffort()
|
private void RemoveLowLevelOfEffort()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort();
|
||||||
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Invert()
|
private void Invert()
|
||||||
{
|
{
|
||||||
var rt = _scheduledTeams.ToArray();
|
_scheduledTeams = _scheduledTeams.Invert(_teams);
|
||||||
_scheduledTeams
|
|
||||||
= _teams.Where(t => !rt.Contains(t));
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,32 +381,15 @@
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
_teams
|
_teams = await DataService.LoadTeamsAsync();
|
||||||
= await Context.Teams
|
_students = await DataService.LoadStudentsAsync();
|
||||||
.AsNoTracking()
|
|
||||||
.Include(e => e.Event)
|
|
||||||
.Include(e => e.Students)
|
|
||||||
.OrderBy(e => e.Event.Name)
|
|
||||||
.ThenBy(e => e.Identifier)
|
|
||||||
.ToArrayAsync();
|
|
||||||
|
|
||||||
_students =
|
|
||||||
await Context.Students
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(e => e.Teams)
|
|
||||||
.ThenInclude(t => t.Event)
|
|
||||||
.Include(e => e.Teams)
|
|
||||||
.ThenInclude(t => t.Captain)
|
|
||||||
.Include(e => e.EventRankings)
|
|
||||||
.ThenInclude(e => e.EventDefinition)
|
|
||||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
|
||||||
|
|
||||||
// Load saved selections from localStorage
|
// Load saved selections from localStorage
|
||||||
await LoadScheduledTeams();
|
_scheduledTeams = await StateService.LoadScheduledTeamsAsync(_teams);
|
||||||
await LoadAbsentStudents();
|
_absentStudents = await StateService.LoadAbsentStudentsAsync(_students);
|
||||||
await LoadTimeSlotCount();
|
_parameters.TimeSlots = await StateService.LoadTimeSlotCountAsync(2);
|
||||||
await LoadExtendedTeams();
|
_extendedTeams = await StateService.LoadExtendedTeamsAsync(_teams);
|
||||||
await LoadExcludedStudents();
|
_excludedStudents = await StateService.LoadExcludedStudentsAsync();
|
||||||
|
|
||||||
// Initialize last saved state from loaded values
|
// Initialize last saved state from loaded values
|
||||||
_lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students);
|
_lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students);
|
||||||
@@ -290,84 +405,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveScheduledTeams()
|
|
||||||
{
|
|
||||||
var teamIds = _scheduledTeams.Select(t => t.Id).ToArray();
|
|
||||||
await LocalStorage.SetIntArrayAsync("MeetingSchedule_ScheduledTeams", teamIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadScheduledTeams()
|
|
||||||
{
|
|
||||||
var teamIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_ScheduledTeams");
|
|
||||||
if (teamIds.Length > 0)
|
|
||||||
{
|
|
||||||
_scheduledTeams = _teams.Where(t => teamIds.Contains(t.Id)).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveAbsentStudents()
|
|
||||||
{
|
|
||||||
var studentIds = _absentStudents.Select(s => s.Id).ToArray();
|
|
||||||
await LocalStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", studentIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAbsentStudents()
|
|
||||||
{
|
|
||||||
var studentIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents");
|
|
||||||
if (studentIds.Length > 0)
|
|
||||||
{
|
|
||||||
_absentStudents = _students.Where(s => studentIds.Contains(s.Id)).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveTimeSlotCount()
|
|
||||||
{
|
|
||||||
await LocalStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", _parameters.TimeSlots);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadTimeSlotCount()
|
|
||||||
{
|
|
||||||
var timeSlots = await LocalStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2);
|
|
||||||
if (timeSlots > 0)
|
|
||||||
{
|
|
||||||
_parameters.TimeSlots = timeSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveExtendedTeams()
|
|
||||||
{
|
|
||||||
var teamIds = _extendedTeams.Select(t => t.Id).ToArray();
|
|
||||||
await LocalStorage.SetIntArrayAsync("MeetingSchedule_ExtendedTeams", teamIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadExtendedTeams()
|
|
||||||
{
|
|
||||||
var teamIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_ExtendedTeams");
|
|
||||||
if (teamIds.Length > 0)
|
|
||||||
{
|
|
||||||
_extendedTeams = _teams.Where(t => teamIds.Contains(t.Id)).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveExcludedStudents()
|
|
||||||
{
|
|
||||||
var exclusions = _excludedStudents.Keys
|
|
||||||
.Where(k => _excludedStudents[k])
|
|
||||||
.Select(k => new ExcludedStudent(k.teamId, k.timeSlotIndex, k.studentId))
|
|
||||||
.ToArray();
|
|
||||||
await LocalStorage.SetJsonAsync("MeetingSchedule_ExcludedStudents", exclusions);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadExcludedStudents()
|
|
||||||
{
|
|
||||||
var exclusions = await LocalStorage.GetJsonAsync<ExcludedStudent[]>("MeetingSchedule_ExcludedStudents");
|
|
||||||
if (exclusions != null && exclusions.Length > 0)
|
|
||||||
{
|
|
||||||
_excludedStudents = exclusions.ToDictionary(
|
|
||||||
e => (e.TeamId, e.TimeSlotIndex, e.StudentId),
|
|
||||||
_ => true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int GetTimeSlotIndex(string timeSlotName)
|
private int GetTimeSlotIndex(string timeSlotName)
|
||||||
{
|
{
|
||||||
@@ -401,37 +438,12 @@
|
|||||||
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates teams with excluded students filtered out for overlap calculation.
|
|
||||||
/// </summary>
|
|
||||||
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
|
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
|
||||||
{
|
{
|
||||||
return teams.Select(team =>
|
var absentStudentIds = _absentStudents.Select(s => s.Id).ToHashSet();
|
||||||
{
|
return OverlapCalculationHelper.GetTeamsWithoutExcludedStudents(teams, timeSlotIndex, _excludedStudents, absentStudentIds);
|
||||||
// Find excluded students for this team in this time slot
|
|
||||||
// More efficient: iterate through team students and check exclusions
|
|
||||||
var includedStudents = team.Students
|
|
||||||
.Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, s.Id))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// If no students are excluded, return original team
|
|
||||||
if (includedStudents.Count == team.Students.Count)
|
|
||||||
return team;
|
|
||||||
|
|
||||||
// Create a temporary team with excluded students removed
|
|
||||||
return new Team
|
|
||||||
{
|
|
||||||
Id = team.Id,
|
|
||||||
Event = team.Event,
|
|
||||||
Students = includedStudents,
|
|
||||||
Captain = team.Captain,
|
|
||||||
Identifier = team.Identifier
|
|
||||||
};
|
|
||||||
}).ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId);
|
|
||||||
|
|
||||||
private bool IsDirty()
|
private bool IsDirty()
|
||||||
{
|
{
|
||||||
if (_lastSavedState == null)
|
if (_lastSavedState == null)
|
||||||
@@ -480,26 +492,7 @@
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// Create PartialTeam instances for teams with excluded students
|
// Create PartialTeam instances for teams with excluded students
|
||||||
// Aggregate exclusions across all time slots (since we don't know assignment yet)
|
var teamsForScheduling = PartialTeam.CreatePartialTeamsFromExclusions(_scheduledTeams, _excludedStudents);
|
||||||
var teamsForScheduling = _scheduledTeams.Select(team =>
|
|
||||||
{
|
|
||||||
// Find all students excluded for this team across all time slots
|
|
||||||
var excludedStudentIds = _excludedStudents.Keys
|
|
||||||
.Where(k => k.teamId == team.Id && _excludedStudents[k])
|
|
||||||
.Select(k => k.studentId)
|
|
||||||
.Distinct()
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
if (excludedStudentIds.Count == 0)
|
|
||||||
return team;
|
|
||||||
|
|
||||||
var excludedStudents = team.Students.Where(s => excludedStudentIds.Contains(s.Id)).ToList();
|
|
||||||
if (excludedStudents.Count == 0)
|
|
||||||
return team;
|
|
||||||
|
|
||||||
// Create PartialTeam with excluded students
|
|
||||||
return team.CloneWithOmittedStudents(excludedStudents);
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
|
var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
|
||||||
_solution = teamScheduler.Solve();
|
_solution = teamScheduler.Solve();
|
||||||
@@ -537,7 +530,11 @@
|
|||||||
// Post-process: extend teams to next consecutive time slot
|
// Post-process: extend teams to next consecutive time slot
|
||||||
if (_extendedTeams.Any())
|
if (_extendedTeams.Any())
|
||||||
{
|
{
|
||||||
ExtendTeamsInSolution(_solution, _extendedTeams, availableStudents);
|
TeamSchedulerPostProcessor.ExtendTeamsInSolution(
|
||||||
|
_solution,
|
||||||
|
_extendedTeams,
|
||||||
|
availableStudents,
|
||||||
|
GetTeamsWithoutExcludedStudents);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try recommendation strategies in priority order
|
// Try recommendation strategies in priority order
|
||||||
@@ -563,6 +560,13 @@
|
|||||||
_excludedStudents);
|
_excludedStudents);
|
||||||
await currentState.SaveToLocalStorage(LocalStorage);
|
await currentState.SaveToLocalStorage(LocalStorage);
|
||||||
_lastSavedState = currentState;
|
_lastSavedState = currentState;
|
||||||
|
|
||||||
|
// Also save via state service for consistency
|
||||||
|
await StateService.SaveScheduledTeamsAsync(_scheduledTeams);
|
||||||
|
await StateService.SaveAbsentStudentsAsync(_absentStudents);
|
||||||
|
await StateService.SaveTimeSlotCountAsync(_parameters.TimeSlots);
|
||||||
|
await StateService.SaveExtendedTeamsAsync(_extendedTeams);
|
||||||
|
await StateService.SaveExcludedStudentsAsync(_excludedStudents);
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
||||||
|
|
||||||
@@ -575,91 +579,18 @@
|
|||||||
_solutionData.ReloadServerData();
|
_solutionData.ReloadServerData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move team extension logic into Core.Calculation.TeamScheduler to handle extended teams
|
|
||||||
// as part of the constraint programming model rather than post-processing
|
|
||||||
private void ExtendTeamsInSolution(TeamSchedulerSolution solution, IEnumerable<Team> extendedTeams, Student[] allStudents)
|
|
||||||
{
|
|
||||||
if (solution.TimeSlots == null || !solution.TimeSlots.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
var extendedTeamsList = extendedTeams.ToList();
|
|
||||||
if (!extendedTeamsList.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
var extendedTeamIds = extendedTeamsList.Select(t => t.Id).ToHashSet();
|
|
||||||
|
|
||||||
// Find which time slot each extended team is in and extend both forward and backward
|
|
||||||
for (int slotIndex = 0; slotIndex < solution.TimeSlots.Length; slotIndex++)
|
|
||||||
{
|
|
||||||
var currentSlot = solution.TimeSlots[slotIndex];
|
|
||||||
var teamsToExtend = currentSlot.Teams.Where(t => extendedTeamIds.Contains(t.Id)).ToList();
|
|
||||||
|
|
||||||
if (!teamsToExtend.Any())
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Extend forward: add to next time slot (if exists)
|
|
||||||
if (slotIndex + 1 < solution.TimeSlots.Length)
|
|
||||||
{
|
|
||||||
var nextSlot = solution.TimeSlots[slotIndex + 1];
|
|
||||||
var nextSlotTeamsList = nextSlot.Teams.ToList();
|
|
||||||
var nextSlotTeamIds = nextSlotTeamsList.Select(t => t.Id).ToHashSet();
|
|
||||||
|
|
||||||
foreach (var team in teamsToExtend)
|
|
||||||
{
|
|
||||||
if (!nextSlotTeamIds.Contains(team.Id))
|
|
||||||
{
|
|
||||||
nextSlotTeamsList.Add(team);
|
|
||||||
nextSlotTeamIds.Add(team.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextSlot.Teams = nextSlotTeamsList.ToArray();
|
|
||||||
var nextSlotIndex = slotIndex + 1;
|
|
||||||
var nextSlotTeamsForOverlap = GetTeamsWithoutExcludedStudents(nextSlot.Teams, nextSlotIndex);
|
|
||||||
nextSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(nextSlotTeamsForOverlap);
|
|
||||||
// Use teams without excluded students so students excluded from all teams appear as unscheduled
|
|
||||||
nextSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(nextSlotTeamsForOverlap, allStudents);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extend backward: add to previous time slot (if exists)
|
|
||||||
if (slotIndex > 0)
|
|
||||||
{
|
|
||||||
var previousSlot = solution.TimeSlots[slotIndex - 1];
|
|
||||||
var previousSlotTeamsList = previousSlot.Teams.ToList();
|
|
||||||
var previousSlotTeamIds = previousSlotTeamsList.Select(t => t.Id).ToHashSet();
|
|
||||||
|
|
||||||
foreach (var team in teamsToExtend)
|
|
||||||
{
|
|
||||||
if (!previousSlotTeamIds.Contains(team.Id))
|
|
||||||
{
|
|
||||||
previousSlotTeamsList.Add(team);
|
|
||||||
previousSlotTeamIds.Add(team.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousSlot.Teams = previousSlotTeamsList.ToArray();
|
|
||||||
var previousSlotIndex = slotIndex - 1;
|
|
||||||
var previousSlotTeamsForOverlap = GetTeamsWithoutExcludedStudents(previousSlot.Teams, previousSlotIndex);
|
|
||||||
previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlotTeamsForOverlap);
|
|
||||||
// Use teams without excluded students so students excluded from all teams appear as unscheduled
|
|
||||||
previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlotTeamsForOverlap, allStudents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task CopyToClipboard()
|
async Task CopyToClipboard()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
|
||||||
foreach (var timeslot in _solution.TimeSlots)
|
|
||||||
{
|
|
||||||
AppendScheduledTeams(sb, timeslot);
|
|
||||||
AppendUnscheduledStudents(sb, timeslot);
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ClipboardService.WriteTextAsync(sb.ToString());
|
var text = ClipboardFormatService.FormatScheduleForClipboard(
|
||||||
|
_solution,
|
||||||
|
_teams,
|
||||||
|
_absentStudents,
|
||||||
|
_excludedStudents,
|
||||||
|
GetTimeSlotIndex);
|
||||||
|
await ClipboardService.WriteTextAsync(text);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -667,220 +598,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
private async Task OpenSaveHistoryDialog()
|
||||||
{
|
{
|
||||||
var timeSlotIndex = GetTimeSlotIndex(timeslot.Name);
|
var parameters = new DialogParameters
|
||||||
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
|
||||||
{
|
{
|
||||||
var teamName = scheduledTeam.ToString();
|
["ScheduledTeams"] = _scheduledTeams,
|
||||||
|
["AbsentStudents"] = _absentStudents,
|
||||||
|
["AllTeams"] = _teams,
|
||||||
|
["AllStudents"] = _students
|
||||||
|
};
|
||||||
|
|
||||||
if (scheduledTeam.Event.EventFormat is EventFormat.Individual)
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Save Meeting History", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
// Note: Success message is already shown in the dialog, no need to show another here
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTeamsFromClipboard()
|
||||||
|
{
|
||||||
|
if (_isLoadingClipboard) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isLoadingClipboard = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var clipboardText = await ClipboardService.ReadTextAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clipboardText))
|
||||||
{
|
{
|
||||||
sb.Append(teamName);
|
Snackbar.Add("Clipboard is empty", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedTeams = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _teams);
|
||||||
|
|
||||||
|
if (!matchedTeams.Any())
|
||||||
|
{
|
||||||
|
Snackbar.Add("No matching teams found in clipboard text", Severity.Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine with existing scheduled teams, avoiding duplicates
|
||||||
|
var existingTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
var newTeams = matchedTeams.Where(t => !existingTeamIds.Contains(t.Id));
|
||||||
|
var updatedTeams = _scheduledTeams.Concat(newTeams).ToList();
|
||||||
|
|
||||||
|
OnScheduledTeamsChanged(updatedTeams);
|
||||||
|
|
||||||
|
var newCount = newTeams.Count();
|
||||||
|
var totalCount = matchedTeams.Count();
|
||||||
|
if (newCount == totalCount)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Selected {totalCount} team(s) from clipboard", Severity.Success);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex);
|
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
|
||||||
sb.Append($"{teamName} - {studentsList}");
|
|
||||||
}
|
}
|
||||||
sb.Append(Environment.NewLine);
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading teams from clipboard: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoadingClipboard = false;
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot, int timeSlotIndex)
|
|
||||||
{
|
|
||||||
// Filter out excluded students for this team and time slot
|
|
||||||
var excludedStudentIds = _excludedStudents.Keys
|
|
||||||
.Where(k => k.teamId == team.Id && k.timeSlotIndex == timeSlotIndex && _excludedStudents[k])
|
|
||||||
.Select(k => k.studentId)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
var includedStudents = team.Students
|
|
||||||
.Where(s => !excludedStudentIds.Contains(s.Id))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Create a temporary team with only included students for formatting
|
|
||||||
var teamForFormatting = new Team
|
|
||||||
{
|
|
||||||
Id = team.Id,
|
|
||||||
Event = team.Event,
|
|
||||||
Students = includedStudents,
|
|
||||||
Captain = team.Captain,
|
|
||||||
Identifier = team.Identifier
|
|
||||||
};
|
|
||||||
|
|
||||||
return TeamStudentNameFormatter.FormatStudentList(
|
|
||||||
teamForFormatting,
|
|
||||||
new TeamStudentNameFormatter.FormatOptions
|
|
||||||
{
|
|
||||||
Ordering = TeamStudentNameFormatter.OrderingStyle.CaptainFirst,
|
|
||||||
MarkOverlaps = true,
|
|
||||||
HasOverlaps = timeslot.StudentHasOverlaps,
|
|
||||||
MarkAbsent = true,
|
|
||||||
AbsentStudents = _absentStudents.ToList()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
|
|
||||||
{
|
|
||||||
// Find the team this student belongs to for formatting context
|
|
||||||
var team = _teams.FirstOrDefault(t => t.Students.Contains(student));
|
|
||||||
if (team == null)
|
|
||||||
{
|
|
||||||
// No team context, use StudentNameFormatter directly
|
|
||||||
return StudentNameFormatter.FormatStudentName(
|
|
||||||
student,
|
|
||||||
new StudentNameFormatter.FormatOptions
|
|
||||||
{
|
|
||||||
HasOverlap = timeslot.StudentHasOverlaps(student),
|
|
||||||
IsAbsent = _absentStudents.Contains(student)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return TeamStudentNameFormatter.FormatStudentName(
|
|
||||||
student,
|
|
||||||
team,
|
|
||||||
new TeamStudentNameFormatter.FormatOptions
|
|
||||||
{
|
|
||||||
MarkOverlaps = true,
|
|
||||||
HasOverlaps = timeslot.StudentHasOverlaps,
|
|
||||||
MarkAbsent = true,
|
|
||||||
AbsentStudents = _absentStudents.ToList()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AppendUnscheduledStudents(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
|
||||||
{
|
|
||||||
if (!timeslot.UnscheduledStudents.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
sb.Append("--Unscheduled");
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
|
|
||||||
foreach (var student in timeslot.UnscheduledStudents)
|
|
||||||
{
|
|
||||||
var studentName = StudentNameFormatter.FormatStudentName(
|
|
||||||
student,
|
|
||||||
new StudentNameFormatter.FormatOptions
|
|
||||||
{
|
|
||||||
IsAbsent = _absentStudents.Contains(student)
|
|
||||||
});
|
|
||||||
|
|
||||||
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
|
||||||
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
|
||||||
|
|
||||||
sb.Append($"{studentName} - {teamsList}");
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class MeetingScheduleState : IEquatable<MeetingScheduleState>
|
|
||||||
{
|
|
||||||
public HashSet<int> ScheduledTeamIds { get; set; } = [];
|
|
||||||
public HashSet<int> AbsentStudentIds { get; set; } = [];
|
|
||||||
public int TimeSlotCount { get; set; }
|
|
||||||
public HashSet<int> ExtendedTeamIds { get; set; } = [];
|
|
||||||
public HashSet<(int teamId, int timeSlotIndex, int studentId)> ExcludedStudents { get; set; } = [];
|
|
||||||
|
|
||||||
public bool Equals(MeetingScheduleState? other)
|
|
||||||
{
|
|
||||||
if (other == null) return false;
|
|
||||||
return ScheduledTeamIds.SetEquals(other.ScheduledTeamIds) &&
|
|
||||||
AbsentStudentIds.SetEquals(other.AbsentStudentIds) &&
|
|
||||||
TimeSlotCount == other.TimeSlotCount &&
|
|
||||||
ExtendedTeamIds.SetEquals(other.ExtendedTeamIds) &&
|
|
||||||
ExcludedStudents.SetEquals(other.ExcludedStudents);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool Equals(object? obj) => Equals(obj as MeetingScheduleState);
|
|
||||||
|
|
||||||
public override int GetHashCode()
|
|
||||||
{
|
|
||||||
var hash = new HashCode();
|
|
||||||
hash.Add(ScheduledTeamIds.Count);
|
|
||||||
foreach (var id in ScheduledTeamIds.OrderBy(x => x))
|
|
||||||
hash.Add(id);
|
|
||||||
hash.Add(AbsentStudentIds.Count);
|
|
||||||
foreach (var id in AbsentStudentIds.OrderBy(x => x))
|
|
||||||
hash.Add(id);
|
|
||||||
hash.Add(TimeSlotCount);
|
|
||||||
hash.Add(ExtendedTeamIds.Count);
|
|
||||||
foreach (var id in ExtendedTeamIds.OrderBy(x => x))
|
|
||||||
hash.Add(id);
|
|
||||||
hash.Add(ExcludedStudents.Count);
|
|
||||||
foreach (var key in ExcludedStudents.OrderBy(x => x))
|
|
||||||
hash.Add(key);
|
|
||||||
return hash.ToHashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static MeetingScheduleState FromCurrent(
|
|
||||||
IEnumerable<Team> scheduledTeams,
|
|
||||||
IEnumerable<Student> absentStudents,
|
|
||||||
int timeSlotCount,
|
|
||||||
IEnumerable<Team> extendedTeams,
|
|
||||||
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
|
||||||
{
|
|
||||||
return new MeetingScheduleState
|
|
||||||
{
|
|
||||||
ScheduledTeamIds = scheduledTeams.Select(t => t.Id).ToHashSet(),
|
|
||||||
AbsentStudentIds = absentStudents.Select(s => s.Id).ToHashSet(),
|
|
||||||
TimeSlotCount = timeSlotCount,
|
|
||||||
ExtendedTeamIds = extendedTeams.Select(t => t.Id).ToHashSet(),
|
|
||||||
ExcludedStudents = excludedStudents.Keys
|
|
||||||
.Where(k => excludedStudents[k])
|
|
||||||
.ToHashSet()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<MeetingScheduleState?> FromLocalStorage(
|
|
||||||
LocalStorageService localStorage,
|
|
||||||
Team[] allTeams,
|
|
||||||
Student[] allStudents)
|
|
||||||
{
|
|
||||||
// Load scheduled teams
|
|
||||||
var scheduledTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ScheduledTeams");
|
|
||||||
var absentStudentIds = await localStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents");
|
|
||||||
var timeSlotCount = await localStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2);
|
|
||||||
var extendedTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ExtendedTeams");
|
|
||||||
var exclusions = await localStorage.GetJsonAsync<ExcludedStudent[]>("MeetingSchedule_ExcludedStudents");
|
|
||||||
|
|
||||||
// If no state exists, return null
|
|
||||||
if (scheduledTeamIds.Length == 0 && absentStudentIds.Length == 0 &&
|
|
||||||
timeSlotCount == 2 && extendedTeamIds.Length == 0 &&
|
|
||||||
(exclusions == null || exclusions.Length == 0))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var excludedStudentsSet = new HashSet<(int teamId, int timeSlotIndex, int studentId)>();
|
|
||||||
if (exclusions != null && exclusions.Length > 0)
|
|
||||||
{
|
|
||||||
foreach (var exclusion in exclusions)
|
|
||||||
{
|
|
||||||
excludedStudentsSet.Add((exclusion.TeamId, exclusion.TimeSlotIndex, exclusion.StudentId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new MeetingScheduleState
|
|
||||||
{
|
|
||||||
ScheduledTeamIds = scheduledTeamIds.ToHashSet(),
|
|
||||||
AbsentStudentIds = absentStudentIds.ToHashSet(),
|
|
||||||
TimeSlotCount = timeSlotCount > 0 ? timeSlotCount : 2,
|
|
||||||
ExtendedTeamIds = extendedTeamIds.ToHashSet(),
|
|
||||||
ExcludedStudents = excludedStudentsSet
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SaveToLocalStorage(LocalStorageService localStorage)
|
|
||||||
{
|
|
||||||
await localStorage.SetIntArrayAsync("MeetingSchedule_ScheduledTeams", ScheduledTeamIds.ToArray());
|
|
||||||
await localStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", AbsentStudentIds.ToArray());
|
|
||||||
await localStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", TimeSlotCount);
|
|
||||||
await localStorage.SetIntArrayAsync("MeetingSchedule_ExtendedTeams", ExtendedTeamIds.ToArray());
|
|
||||||
|
|
||||||
var exclusions = ExcludedStudents.Select(e => new ExcludedStudent(e.teamId, e.timeSlotIndex, e.studentId)).ToArray();
|
|
||||||
await localStorage.SetJsonAsync("MeetingSchedule_ExcludedStudents", exclusions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
@namespace WebApp.Components.Features.MeetingSchedule
|
||||||
|
@using Core.Services
|
||||||
|
@using WebApp.Models
|
||||||
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject INoteNamingService NoteNamingService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject IMeetingScheduleDataService DataService
|
||||||
|
@inject IMeetingScheduleStateService StateService
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else if (_meetingHistory == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error">Meeting history not found.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
@* Navigation Header *@
|
||||||
|
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudTooltip Text="@(_previousMeetingHistory != null ? $"Previous: {_previousMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No previous meeting")">
|
||||||
|
<span>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||||
|
OnClick="NavigateToPrevious"
|
||||||
|
Disabled="@(_previousMeetingHistory == null || IsActionDisabled)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
</span>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudText Typo="Typo.h6" Class="flex-grow-1" Style="text-align: center;">@_meetingHistory.MeetingDate.ToString("MM/dd/yyyy")</MudText>
|
||||||
|
<MudTooltip Text="@(_nextMeetingHistory != null ? $"Next: {_nextMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No next meeting")">
|
||||||
|
<span>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||||
|
OnClick="NavigateToNext"
|
||||||
|
Disabled="@(_nextMeetingHistory == null || IsActionDisabled)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
</span>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudText Typo="Typo.subtitle1">Teams That Met (@_meetingHistory.Teams.Count)</MudText>
|
||||||
|
<MudPaper Elevation="1" Class="pa-2">
|
||||||
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||||
|
@foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="@AppIcons.TeamChipVariant()" Class="mx-1 my-1">@team.ToString()</MudChip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudText Typo="Typo.subtitle1">Students (@GetAllStudentsFromTeams().Count)</MudText>
|
||||||
|
<MudPaper Elevation="1" Class="pa-2">
|
||||||
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||||
|
@{
|
||||||
|
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName);
|
||||||
|
}
|
||||||
|
@foreach (var student in allStudents)
|
||||||
|
{
|
||||||
|
var isPresent = presentStudentIds.Contains(student.Id);
|
||||||
|
<MudChip T="string"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Default"
|
||||||
|
Variant="@AppIcons.StudentChipVariant()"
|
||||||
|
Class="mx-1 my-1"
|
||||||
|
Style="@(!isPresent ? "opacity: 0.5;" : "")">
|
||||||
|
@student.FirstNameLastName
|
||||||
|
</MudChip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@if (_meetingNote != null)
|
||||||
|
{
|
||||||
|
<MudDivider />
|
||||||
|
<MudText Typo="Typo.subtitle1">Meeting Notes</MudText>
|
||||||
|
<MudPaper Elevation="1" Class="pa-3">
|
||||||
|
<MudText Typo="Typo.body2"><strong>@_meetingNote.Title</strong></MudText>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_meetingNote.Content))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mt-2">
|
||||||
|
@((MarkupString)MarkdownHelper.ToHtml(_meetingNote.Content))
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Edit"
|
||||||
|
OnClick="ViewNote"
|
||||||
|
Class="mt-2">
|
||||||
|
Edit Note
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
@if (_meetingHistory != null)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Error"
|
||||||
|
OnClick="ConfirmDelete"
|
||||||
|
Disabled="@IsActionDisabled">
|
||||||
|
Delete
|
||||||
|
</MudButton>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
OnClick="LoadIntoPlanner"
|
||||||
|
Disabled="@IsActionDisabled"
|
||||||
|
StartIcon="@Icons.Material.Filled.Upload">
|
||||||
|
Load into Planner
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="OpenEditDialog"
|
||||||
|
Disabled="@IsActionDisabled"
|
||||||
|
StartIcon="@Icons.Material.Filled.Edit">
|
||||||
|
Edit
|
||||||
|
</MudButton>
|
||||||
|
<MudButton OnClick="Close" Disabled="@IsActionDisabled">Close</MudButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton OnClick="Close">Close</MudButton>
|
||||||
|
}
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int MeetingHistoryId { get; set; }
|
||||||
|
|
||||||
|
private TeamMeetingHistory? _meetingHistory;
|
||||||
|
private Note? _meetingNote;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private bool _isDeleting = false;
|
||||||
|
private Team[] _allTeams = [];
|
||||||
|
private Student[] _allStudents = [];
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
private bool IsActionDisabled => _isLoading || _isDeleting;
|
||||||
|
private TeamMeetingHistory? _previousMeetingHistory;
|
||||||
|
private TeamMeetingHistory? _nextMeetingHistory;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// Load all teams and students for the edit dialog
|
||||||
|
_allTeams = await DataService.LoadTeamsAsync();
|
||||||
|
_allStudents = await DataService.LoadStudentsAsync();
|
||||||
|
await LoadMeetingHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMeetingHistory()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_meetingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId);
|
||||||
|
|
||||||
|
// Load note by title if meeting history exists
|
||||||
|
if (_meetingHistory != null)
|
||||||
|
{
|
||||||
|
_meetingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(_meetingHistory.MeetingDate);
|
||||||
|
|
||||||
|
// Load all meeting histories to find previous/next
|
||||||
|
await LoadNavigationMeetings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadNavigationMeetings()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all meeting histories ordered by date
|
||||||
|
var allMeetings = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync())
|
||||||
|
.OrderBy(m => m.MeetingDate)
|
||||||
|
.ThenBy(m => m.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var currentIndex = allMeetings.FindIndex(m => m.Id == _meetingHistory.Id);
|
||||||
|
|
||||||
|
if (currentIndex >= 0)
|
||||||
|
{
|
||||||
|
_previousMeetingHistory = currentIndex > 0 ? allMeetings[currentIndex - 1] : null;
|
||||||
|
_nextMeetingHistory = currentIndex < allMeetings.Count - 1 ? allMeetings[currentIndex + 1] : null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_previousMeetingHistory = null;
|
||||||
|
_nextMeetingHistory = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
// Log error but don't show snackbar - navigation is not critical
|
||||||
|
System.Diagnostics.Debug.WriteLine($"Error loading navigation meetings: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NavigateToPrevious()
|
||||||
|
{
|
||||||
|
if (_previousMeetingHistory == null || _isDisposed) return;
|
||||||
|
|
||||||
|
// Close current dialog and open new one with previous meeting ID
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = _previousMeetingHistory.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
MudDialog.Close();
|
||||||
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NavigateToNext()
|
||||||
|
{
|
||||||
|
if (_nextMeetingHistory == null || _isDisposed) return;
|
||||||
|
|
||||||
|
// Close current dialog and open new one with next meeting ID
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = _nextMeetingHistory.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
MudDialog.Close();
|
||||||
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmDelete()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"Confirm Delete",
|
||||||
|
$"Are you sure you want to delete the meeting history for {_meetingHistory.MeetingDate:MM/dd/yyyy}? This action cannot be undone.",
|
||||||
|
yesText: "Delete",
|
||||||
|
cancelText: "Cancel");
|
||||||
|
|
||||||
|
if (result == true)
|
||||||
|
{
|
||||||
|
await DeleteMeetingHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteMeetingHistory()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isDeleting = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
await TeamMeetingHistoryService.DeleteMeetingHistoryAsync(MeetingHistoryId);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Meeting history deleted successfully", Severity.Success);
|
||||||
|
MudDialog.Close(DialogResult.Ok(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error deleting meeting history: {ex.Message}", Severity.Error);
|
||||||
|
_isDeleting = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ViewNote()
|
||||||
|
{
|
||||||
|
if (_meetingNote == null) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["NoteId"] = _meetingNote.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<NoteViewDialog>("Meeting Notes", parameters, options);
|
||||||
|
await dialog.Result;
|
||||||
|
|
||||||
|
// Refresh meeting history to get updated note
|
||||||
|
await LoadMeetingHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenEditDialog()
|
||||||
|
{
|
||||||
|
if (_meetingHistory == null || _isDisposed) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = _meetingHistory.Id,
|
||||||
|
["AllTeams"] = _allTeams,
|
||||||
|
["AllStudents"] = _allStudents,
|
||||||
|
["ScheduledTeams"] = new List<Team>(), // Not used when editing
|
||||||
|
["AbsentStudents"] = new List<Student>() // Not used when editing
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Edit Meeting History", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
// Refresh meeting history if dialog was saved
|
||||||
|
if (!result.Canceled && !_isDisposed)
|
||||||
|
{
|
||||||
|
await LoadMeetingHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Student> GetAllStudentsFromTeams()
|
||||||
|
{
|
||||||
|
if (_meetingHistory == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var allStudents = new List<Student>();
|
||||||
|
var studentIds = new HashSet<int>();
|
||||||
|
|
||||||
|
foreach (var team in _meetingHistory.Teams)
|
||||||
|
{
|
||||||
|
foreach (var student in team.Students)
|
||||||
|
{
|
||||||
|
if (!studentIds.Contains(student.Id))
|
||||||
|
{
|
||||||
|
studentIds.Add(student.Id);
|
||||||
|
allStudents.Add(student);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStudents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadIntoPlanner()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all teams and students from database
|
||||||
|
var allTeams = await DataService.LoadTeamsAsync();
|
||||||
|
var allStudents = await DataService.LoadStudentsAsync();
|
||||||
|
|
||||||
|
// Match teams from history to all teams by ID for reference equality
|
||||||
|
var historyTeamIds = _meetingHistory.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
var scheduledTeams = allTeams.Where(t => historyTeamIds.Contains(t.Id));
|
||||||
|
|
||||||
|
// Calculate absent students (all students not in the meeting history's student list)
|
||||||
|
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
var absentStudents = allStudents.Where(s => !presentStudentIds.Contains(s.Id));
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
await StateService.SaveScheduledTeamsAsync(scheduledTeams);
|
||||||
|
await StateService.SaveAbsentStudentsAsync(absentStudents);
|
||||||
|
// Clear extended teams and excluded students when loading from history
|
||||||
|
await StateService.SaveExtendedTeamsAsync([]);
|
||||||
|
await StateService.SaveExcludedStudentsAsync(new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>());
|
||||||
|
|
||||||
|
// Close dialog and navigate to planner
|
||||||
|
MudDialog.Close();
|
||||||
|
NavigationManager.NavigateTo("/meeting-schedule");
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Loaded meeting from {_meetingHistory.MeetingDate:MM/dd/yyyy} into planner", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting into planner: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
MudDialog.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
@namespace WebApp.Components.Features.MeetingSchedule
|
||||||
|
@using Core.Entities
|
||||||
|
@using Core.Calculation
|
||||||
|
@using Core.Services
|
||||||
|
@using WebApp.Services
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Components.Features.Teams.Components
|
||||||
|
@using WebApp.Components.Features.Students.Components
|
||||||
|
@using WebApp.Models
|
||||||
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject INoteNamingService NoteNamingService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.h6">@(_isEditMode ? "Edit Meeting History" : "Save Meeting History")</MudText>
|
||||||
|
|
||||||
|
<MudDatePicker Label="Meeting Date"
|
||||||
|
Date="_meetingDate"
|
||||||
|
DateChanged="OnMeetingDateChanged"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Disabled="@_isEditMode" />
|
||||||
|
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<TeamToggleSelector Teams="@AllTeams"
|
||||||
|
SelectedTeams="_selectedTeams"
|
||||||
|
SelectedTeamsChanged="OnTeamsChanged"
|
||||||
|
Title="Teams That Met"
|
||||||
|
ShowEventAttributes="false" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<StudentToggleSelector Students="@AllStudents"
|
||||||
|
SelectedStudents="_selectedStudents"
|
||||||
|
SelectedStudentsChanged="OnStudentsChanged"
|
||||||
|
Title="Students Present"
|
||||||
|
ShowFullName="true" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
<MudExpansionPanels MultiExpansion="false">
|
||||||
|
<MudExpansionPanel Text="Meeting Notes (Optional)">
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudTextField T="string"
|
||||||
|
Label="Note Title"
|
||||||
|
@bind-Value="_noteTitle"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
ReadOnly="true"
|
||||||
|
HelperText="Title is automatically generated based on meeting date" />
|
||||||
|
<MudTextField T="string"
|
||||||
|
Label="Note Content"
|
||||||
|
@bind-Value="_noteContent"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Lines="5"
|
||||||
|
Placeholder="Enter meeting notes..."
|
||||||
|
HelperText="Optional markdown content for meeting notes" />
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="Cancel" Disabled="@_isSaving">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@IsSaveDisabled">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||||
|
<span>Saving...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Save</span>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> AbsentStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> AllTeams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> AllStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int? MeetingHistoryId { get; set; }
|
||||||
|
|
||||||
|
private DateTime? _meetingDate = DateTime.Today;
|
||||||
|
private IEnumerable<Team> _selectedTeams = [];
|
||||||
|
private IEnumerable<Student> _selectedStudents = [];
|
||||||
|
private string _noteTitle = "";
|
||||||
|
private string _noteContent = "";
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private bool _isSaving = false;
|
||||||
|
private bool _isEditMode = false;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadInitialData()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// If editing, load existing meeting history
|
||||||
|
if (MeetingHistoryId.HasValue)
|
||||||
|
{
|
||||||
|
_isEditMode = true;
|
||||||
|
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId.Value);
|
||||||
|
|
||||||
|
if (existingHistory != null)
|
||||||
|
{
|
||||||
|
_meetingDate = existingHistory.MeetingDate;
|
||||||
|
|
||||||
|
// Match teams by ID to ensure reference equality with MudToggleGroup
|
||||||
|
var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
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
|
||||||
|
var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
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
|
||||||
|
var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate);
|
||||||
|
if (existingNote != null)
|
||||||
|
{
|
||||||
|
_noteContent = existingNote.Content ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Initialize selected teams from scheduled teams
|
||||||
|
_selectedTeams = ScheduledTeams;
|
||||||
|
|
||||||
|
// Initialize selected students (all students except absent ones)
|
||||||
|
var absentStudentIds = AbsentStudents.Select(s => s.Id).ToHashSet();
|
||||||
|
_selectedStudents = AllStudents.Where(s => !absentStudentIds.Contains(s.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate default note title if meeting date is set
|
||||||
|
if (_meetingDate.HasValue)
|
||||||
|
{
|
||||||
|
_noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading data: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsSaveDisabled => _isSaving || _isLoading;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (_meetingDate.HasValue && string.IsNullOrEmpty(_noteTitle))
|
||||||
|
{
|
||||||
|
_noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
|
||||||
|
}
|
||||||
|
await base.OnParametersSetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMeetingDateChanged(DateTime? date)
|
||||||
|
{
|
||||||
|
_meetingDate = date;
|
||||||
|
if (date.HasValue)
|
||||||
|
{
|
||||||
|
_noteTitle = NoteNamingService.GetMeetingNoteTitle(date.Value);
|
||||||
|
}
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTeamsChanged(IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
_selectedTeams = teams;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStudentsChanged(IEnumerable<Student> students)
|
||||||
|
{
|
||||||
|
_selectedStudents = students;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _isSaving) return;
|
||||||
|
|
||||||
|
if (!_meetingDate.HasValue)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Please select a meeting date", Severity.Warning);
|
||||||
|
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)
|
||||||
|
if (!_isEditMode)
|
||||||
|
{
|
||||||
|
var dateOnly = _meetingDate.Value.Date;
|
||||||
|
// GetMeetingHistoriesAsync: startDate >= date, endDate < (endDate.Date + 1 day)
|
||||||
|
// To get meetings for a single day, pass dateOnly as startDate and dateOnly as endDate
|
||||||
|
// This becomes: MeetingDate >= dateOnly AND MeetingDate < (dateOnly + 1 day) = just that day
|
||||||
|
var existingMeetings = await TeamMeetingHistoryService.GetMeetingHistoriesAsync(dateOnly, dateOnly);
|
||||||
|
if (existingMeetings.Any())
|
||||||
|
{
|
||||||
|
// Show confirmation dialog
|
||||||
|
var confirmResult = await DialogService.ShowMessageBox(
|
||||||
|
"Overwrite Meeting?",
|
||||||
|
"A meeting already exists for this date. Overwrite it?",
|
||||||
|
yesText: "Overwrite",
|
||||||
|
cancelText: "Cancel");
|
||||||
|
|
||||||
|
if (confirmResult != true)
|
||||||
|
{
|
||||||
|
return; // User cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// User confirmed, switch to edit mode
|
||||||
|
var existingMeeting = existingMeetings.First();
|
||||||
|
_isEditMode = true;
|
||||||
|
MeetingHistoryId = existingMeeting.Id;
|
||||||
|
|
||||||
|
// Load existing meeting data
|
||||||
|
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(existingMeeting.Id);
|
||||||
|
if (existingHistory != null)
|
||||||
|
{
|
||||||
|
// Match teams by ID to ensure reference equality with MudToggleGroup
|
||||||
|
var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
_selectedTeams = AllTeams.Where(t => selectedTeamIds.Contains(t.Id));
|
||||||
|
|
||||||
|
// Match students by ID to ensure reference equality with MudToggleGroup
|
||||||
|
var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
_selectedStudents = AllStudents.Where(s => selectedStudentIds.Contains(s.Id));
|
||||||
|
|
||||||
|
// Load existing note if available
|
||||||
|
var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate);
|
||||||
|
if (existingNote != null)
|
||||||
|
{
|
||||||
|
_noteContent = existingNote.Content ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isSaving = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// Create or update note if note content is provided
|
||||||
|
Note? note = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(_noteContent))
|
||||||
|
{
|
||||||
|
// Use the naming service to get the meeting note title
|
||||||
|
var noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
|
||||||
|
|
||||||
|
// Check if note already exists
|
||||||
|
var existingNote = await NotesService.GetNotesAsync(includeDeleted: false);
|
||||||
|
note = existingNote.FirstOrDefault(n => n.Title == noteTitle);
|
||||||
|
|
||||||
|
if (note != null)
|
||||||
|
{
|
||||||
|
// Update existing note
|
||||||
|
note.Content = _noteContent;
|
||||||
|
note = await NotesService.UpdateNoteAsync(note);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create new note
|
||||||
|
note = new Note
|
||||||
|
{
|
||||||
|
Title = noteTitle,
|
||||||
|
Content = _noteContent
|
||||||
|
};
|
||||||
|
note = await NotesService.CreateNoteAsync(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update meeting history
|
||||||
|
TeamMeetingHistory meetingHistory;
|
||||||
|
if (_isEditMode && MeetingHistoryId.HasValue)
|
||||||
|
{
|
||||||
|
// Update existing meeting history
|
||||||
|
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId.Value);
|
||||||
|
if (existingHistory == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Meeting history not found", Severity.Error);
|
||||||
|
_isSaving = false;
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have teams and students - use existing if selected lists are empty (fallback)
|
||||||
|
var teamsToSave = _selectedTeams.Any() ? _selectedTeams.ToList() : existingHistory.Teams.ToList();
|
||||||
|
var studentsToSave = _selectedStudents.Any() ? _selectedStudents.ToList() : existingHistory.Students.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);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Meeting history updated for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create new meeting history
|
||||||
|
meetingHistory = new TeamMeetingHistory
|
||||||
|
{
|
||||||
|
MeetingDate = _meetingDate.Value,
|
||||||
|
Teams = _selectedTeams.ToList(),
|
||||||
|
Students = _selectedStudents.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await TeamMeetingHistoryService.CreateMeetingHistoryAsync(meetingHistory);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Meeting history saved for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
MudDialog.Close(DialogResult.Ok(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error saving meeting history: {ex.Message}", Severity.Error);
|
||||||
|
_isSaving = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
MudDialog.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,14 @@
|
|||||||
BackButtonUrl="@(ReturnUrl ?? "/students")">
|
BackButtonUrl="@(ReturnUrl ?? "/students")">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<div class="no-print">
|
<div class="no-print">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
<MudTooltip Text="Print">
|
||||||
OnClick="PrintPage"
|
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||||
Variant="Variant.Outlined">Print</MudButton>
|
OnClick="PrintPage"
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton>
|
Variant="Variant.Outlined">Print</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Edit">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</div>
|
</div>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@page "/students/event-ranking"
|
@page "/students/event-ranking"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
Title="Student Event Ranks"
|
Title="Student Event Ranks"
|
||||||
Icon="@AppIcons.EventRank" />
|
Icon="@AppIcons.EventRank">
|
||||||
|
<ActionButtons>
|
||||||
|
<PageNoteButton PageIdentifier="Event Ranking" />
|
||||||
|
</ActionButtons>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
@if (_students == null)
|
@if (_students == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ else
|
|||||||
ShowBackButton="true"
|
ShowBackButton="true"
|
||||||
BackButtonUrl="/students/event-ranking">
|
BackButtonUrl="/students/event-ranking">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
<MudTooltip Text="Save Rankings">
|
||||||
OnClick="Save"
|
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||||
Color="Color.Primary"
|
OnClick="Save"
|
||||||
Variant="Variant.Filled">
|
Color="Color.Primary"
|
||||||
Save Rankings
|
Variant="Variant.Filled">
|
||||||
</MudButton>
|
Save Rankings
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,15 @@
|
|||||||
|
|
||||||
<PageHeader Title="Students">
|
<PageHeader Title="Students">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
<MudTooltip Text="Create New">
|
||||||
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||||
<MudButton StartIcon="@AppIcons.Registration" Href="students/teams" Variant="Variant.Outlined">Registration</MudButton>
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Event Rankings">
|
||||||
|
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Registration">
|
||||||
|
<MudButton StartIcon="@AppIcons.Registration" Href="students/teams" Variant="Variant.Outlined">Registration</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,15 @@
|
|||||||
|
|
||||||
<PageHeader Title="Registration" Icon="@AppIcons.Registration">
|
<PageHeader Title="Registration" Icon="@AppIcons.Registration">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
|
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
|
||||||
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
|
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
|
||||||
Color="Color.Primary"
|
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
|
||||||
OnClick="ToggleRegionalFilter">
|
Color="Color.Primary"
|
||||||
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")
|
OnClick="ToggleRegionalFilter">
|
||||||
</MudButton>
|
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<PageNoteButton PageIdentifier="Registration" />
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@page "/teams/assignment"
|
@page "/teams/assignment"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
@using Core.Validation
|
@using Core.Validation
|
||||||
@@ -15,9 +15,12 @@
|
|||||||
Title="Assignment"
|
Title="Assignment"
|
||||||
Description="Optimized team assignments based on the student event rankings">
|
Description="Optimized team assignments based on the student event rankings">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined">
|
<MudTooltip Text="Edit Student Event Rankings">
|
||||||
Edit Student Event Rankings
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined">
|
||||||
</MudButton>
|
Edit Student Event Rankings
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<PageNoteButton PageIdentifier="Team Assignment" />
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -19,12 +21,21 @@
|
|||||||
BackButtonUrl="@(ReturnUrl ?? "/teams")">
|
BackButtonUrl="@(ReturnUrl ?? "/teams")">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<div class="no-print">
|
<div class="no-print">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
<MudTooltip Text="Print">
|
||||||
OnClick="PrintPage"
|
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||||
Variant="Variant.Outlined">Print</MudButton>
|
OnClick="PrintPage"
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
Variant="Variant.Outlined">Print</MudButton>
|
||||||
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")"
|
</MudTooltip>
|
||||||
Variant="Variant.Outlined">Edit</MudButton>
|
<MudTooltip Text="Edit">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||||
|
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")"
|
||||||
|
Variant="Variant.Outlined">Edit</MudButton>
|
||||||
|
</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>
|
||||||
@@ -82,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@page "/teams"
|
@page "/teams"
|
||||||
@@ -10,15 +10,27 @@
|
|||||||
|
|
||||||
<PageHeader Title="Teams">
|
<PageHeader Title="Teams">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="teams/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
<MudTooltip Text="Create New">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/printout" Variant="Variant.Outlined">Printout</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="teams/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
</MudTooltip>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
|
<MudTooltip Text="Printout">
|
||||||
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
|
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/printout" Variant="Variant.Outlined">Printout</MudButton>
|
||||||
Color="Color.Primary"
|
</MudTooltip>
|
||||||
OnClick="ToggleRegionalFilter">
|
<MudTooltip Text="Handout">
|
||||||
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")
|
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
||||||
</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)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="ToggleRegionalFilter">
|
||||||
|
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<PageNoteButton PageIdentifier="Teams" />
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -38,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")" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@page "/teams/printout"
|
@page "/teams/printout"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -110,7 +109,7 @@ else
|
|||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
|
|
||||||
<MudTd>@context.Name</MudTd>
|
<MudTd>@context.Name</MudTd>
|
||||||
<MudTd>@context.Teams.Sum(e => e.Event.LevelOfEffort)</MudTd>
|
<MudTd>@context.Teams.Sum(e => e.Event?.LevelOfEffort ?? 0)</MudTd>
|
||||||
@{
|
@{
|
||||||
var teams = context.Teams
|
var teams = context.Teams
|
||||||
.OrderBy(e =>
|
.OrderBy(e =>
|
||||||
@@ -169,7 +168,7 @@ else
|
|||||||
|
|
||||||
<MudTd>@context.Name</MudTd>
|
<MudTd>@context.Name</MudTd>
|
||||||
<MudTd>@AppIcons.GetOrdinal(context.Grade), @context.TsaYear</MudTd>
|
<MudTd>@AppIcons.GetOrdinal(context.Grade), @context.TsaYear</MudTd>
|
||||||
<MudTd>@context.Teams.Sum(e => e.Event.LevelOfEffort)
|
<MudTd>@context.Teams.Sum(e => e.Event?.LevelOfEffort ?? 0)
|
||||||
@if (!context.Teams.Select(e => e.Event).Any(re => re.OnSiteActivity))
|
@if (!context.Teams.Select(e => e.Event).Any(re => re.OnSiteActivity))
|
||||||
{
|
{
|
||||||
<span>No On-Site Activity</span>
|
<span>No On-Site Activity</span>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,18 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
|
@using Core.Entities
|
||||||
|
@using WebApp.Services
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using Heron.MudCalendar
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||||||
|
@inject ICalendarService CalendarService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle>
|
<PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle>
|
||||||
|
|
||||||
@@ -26,6 +36,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_pinnedNotes.Any())
|
||||||
|
{
|
||||||
|
<MudPaper Elevation="0" Class="mb-4">
|
||||||
|
<MudGrid>
|
||||||
|
@foreach (var note in _pinnedNotes)
|
||||||
|
{
|
||||||
|
<NoteCard Note="@note" OnNoteChanged="HandleNoteChanged" />
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!_hasStudents)
|
@if (!_hasStudents)
|
||||||
{
|
{
|
||||||
<!-- Getting Started: No students yet -->
|
<!-- Getting Started: No students yet -->
|
||||||
@@ -105,14 +127,77 @@ else
|
|||||||
</MudPaper>
|
</MudPaper>
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<DashboardCard Icon="@AppIcons.Scheduler"
|
<DashboardCard Icon="@AppIcons.Scheduler"
|
||||||
Title="Meeting Schedule"
|
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">
|
||||||
@@ -132,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>
|
||||||
@@ -171,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 {
|
||||||
@@ -182,13 +267,59 @@ 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 CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
private bool _hasStudents => _studentCount > 0;
|
private bool _hasStudents => _studentCount > 0;
|
||||||
private bool _hasTeams => _teamCount > 0;
|
private bool _hasTeams => _teamCount > 0;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadStatistics();
|
await LoadStatistics();
|
||||||
|
await LoadPinnedNotes();
|
||||||
|
await LoadMeetingHistoryCount();
|
||||||
|
await LoadNextCalendarItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleNoteChanged()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
await LoadPinnedNotes();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPinnedNotes()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
_pinnedNotes = (await NotesService.GetPinnedNotesAsync()).ToList();
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Error loading pinned notes - ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadStatistics()
|
private async Task LoadStatistics()
|
||||||
@@ -225,4 +356,95 @@ else
|
|||||||
_individualTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Individual);
|
_individualTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Individual);
|
||||||
_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()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
@using Core.Entities
|
@using Core.Entities
|
||||||
|
@using Core.Services
|
||||||
@using WebApp.Services
|
@using WebApp.Services
|
||||||
@using PSC.Blazor.Components.MarkdownEditor
|
@using PSC.Blazor.Components.MarkdownEditor
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
@inject INotesService NotesService
|
@inject INotesService NotesService
|
||||||
|
@inject INoteNamingService NoteNamingService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
|
@inject MarkdownTablePasteService MarkdownTablePasteService
|
||||||
|
|
||||||
<MudDialog>
|
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudStack Spacing="3">
|
<MudStack Spacing="3">
|
||||||
<MudTextField @bind-Value="_note.Title"
|
<MudTextField @bind-Value="_note.Title"
|
||||||
@@ -14,7 +17,8 @@
|
|||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Required="true"
|
Required="true"
|
||||||
RequiredError="Title is required"
|
RequiredError="Title is required"
|
||||||
MaxLength="200" />
|
MaxLength="200"
|
||||||
|
ReadOnly="@IsPageNote" />
|
||||||
|
|
||||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText>
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText>
|
||||||
<MarkdownEditor Value="@_note.Content"
|
<MarkdownEditor Value="@_note.Content"
|
||||||
@@ -41,6 +45,9 @@
|
|||||||
public bool IsEdit { get; set; }
|
public bool IsEdit { get; set; }
|
||||||
|
|
||||||
private Note _note = null!;
|
private Note _note = null!;
|
||||||
|
private bool _pasteMarkdownInitialized = false;
|
||||||
|
|
||||||
|
private bool IsPageNote => Note?.Title != null && NoteNamingService.IsPageNote(Note.Title);
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
@@ -52,6 +59,17 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && !_pasteMarkdownInitialized)
|
||||||
|
{
|
||||||
|
// Initialize paste-markdown after EasyMDE has rendered
|
||||||
|
await Task.Delay(150); // Wait for EasyMDE to initialize
|
||||||
|
await MarkdownTablePasteService.InitializeAsync();
|
||||||
|
_pasteMarkdownInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task Save()
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_note.Title))
|
if (string.IsNullOrWhiteSpace(_note.Title))
|
||||||
@@ -83,6 +101,6 @@
|
|||||||
|
|
||||||
private void Cancel()
|
private void Cancel()
|
||||||
{
|
{
|
||||||
MudDialog.Cancel();
|
MudDialog.Close(DialogResult.Cancel());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
@inject INotesService NotesService
|
@inject INotesService NotesService
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
<MudDialog>
|
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(NoteId)">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@if (_isLoading)
|
@if (_isLoading)
|
||||||
{
|
{
|
||||||
@@ -97,6 +97,8 @@
|
|||||||
"Created" => Color.Success,
|
"Created" => Color.Success,
|
||||||
"Updated" => Color.Info,
|
"Updated" => Color.Info,
|
||||||
"Deleted" => Color.Error,
|
"Deleted" => Color.Error,
|
||||||
|
"Soft Deleted" => Color.Error,
|
||||||
|
"Restored" => Color.Success,
|
||||||
_ => Color.Default
|
_ => Color.Default
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -110,7 +112,7 @@
|
|||||||
|
|
||||||
var options = new DialogOptions
|
var options = new DialogOptions
|
||||||
{
|
{
|
||||||
MaxWidth = MaxWidth.Large,
|
MaxWidth = MaxWidth.Medium,
|
||||||
FullWidth = true,
|
FullWidth = true,
|
||||||
CloseButton = true
|
CloseButton = true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@using WebApp.Services
|
@using WebApp.Services
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
|
|
||||||
<MudDialog>
|
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_history.NoteId)">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<MudStack Spacing="3">
|
<MudStack Spacing="3">
|
||||||
<MudText Typo="Typo.h6">@_history.Title</MudText>
|
<MudText Typo="Typo.h6">@_history.Title</MudText>
|
||||||
|
|||||||
@@ -2,20 +2,33 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@using Core.Entities
|
@using Core.Entities
|
||||||
|
@using Core.Services
|
||||||
@using WebApp.Services
|
@using WebApp.Services
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@inject INotesService NotesService
|
@inject INotesService NotesService
|
||||||
|
@inject INoteNamingService NoteNamingService
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<PageHeader Title="Notes">
|
<PageHeader Title="Notes">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
<MudTooltip Text="@(_showRemoved ? "Show Active" : "Show Removed")">
|
||||||
OnClick="OpenCreateDialog"
|
<MudButton StartIcon="@(_showRemoved ? Icons.Material.Filled.List : Icons.Material.Filled.DeleteOutline)"
|
||||||
Variant="Variant.Filled"
|
OnClick="ToggleRemovedFilter"
|
||||||
Color="Color.Primary">
|
Variant="Variant.Outlined"
|
||||||
Create Note
|
Color="@(_showRemoved ? Color.Warning : Color.Default)">
|
||||||
</MudButton>
|
@(_showRemoved ? "Show Active" : "Show Removed")
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Create Note">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
OnClick="OpenCreateDialog"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary">
|
||||||
|
Create Note
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -32,59 +45,129 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudExpansionPanels MultiExpansion="true">
|
<ClientSidePagination T="Note" Items="@_notes" DefaultPageSize="@DefaultPageSize">
|
||||||
@foreach (var note in _notes)
|
<ChildContent Context="paginatedNotes">
|
||||||
{
|
<MudExpansionPanels MultiExpansion="true">
|
||||||
<MudExpansionPanel Text="@note.Title"
|
@foreach (var note in paginatedNotes)
|
||||||
Icon="@Icons.Material.Filled.Note">
|
{
|
||||||
<MudStack Spacing="2">
|
var noteId = note.Id;
|
||||||
@if (!string.IsNullOrWhiteSpace(note.Content))
|
var initialExpanded = _selectedNoteId.HasValue && noteId == _selectedNoteId.Value;
|
||||||
|
if (!_expandedNotes.ContainsKey(noteId))
|
||||||
{
|
{
|
||||||
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
_expandedNotes[noteId] = initialExpanded;
|
||||||
<div class="markdown-content">
|
|
||||||
@((MarkupString)MarkdownHelper.ToHtml(note.Content))
|
|
||||||
</div>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
var isExpanded = GetNoteExpanded(noteId);
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
<MudExpansionPanel @key="@($"note-{noteId}")"
|
||||||
Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown")
|
Icon="@Icons.Material.Filled.Note"
|
||||||
</MudText>
|
IsExpanded="@isExpanded"
|
||||||
<MudStack Row="true" Spacing="2">
|
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.History"
|
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
|
||||||
OnClick="() => OpenHistoryDialog(note.Id)"
|
<TitleContent>
|
||||||
Variant="Variant.Text"
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="flex-grow-1">
|
||||||
Size="Size.Small">
|
<MudText Typo="Typo.body1" Class="flex-grow-1" Style="min-width: 0;">
|
||||||
History
|
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;">
|
||||||
</MudButton>
|
@GetNoteHeaderText(note, isExpanded)
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
</span>
|
||||||
OnClick="() => OpenEditDialog(note)"
|
</MudText>
|
||||||
Variant="Variant.Text"
|
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
|
||||||
Size="Size.Small"
|
{
|
||||||
Color="Color.Primary">
|
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
|
||||||
Edit
|
OnClick="() => TogglePin(note)"
|
||||||
</MudButton>
|
OnClick:StopPropagation="true"
|
||||||
<MudButton StartIcon="@Icons.Material.Outlined.Delete"
|
Variant="Variant.Text"
|
||||||
OnClick="() => DeleteNote(note)"
|
Size="Size.Small"
|
||||||
Variant="Variant.Text"
|
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
|
||||||
Size="Size.Small"
|
Disabled="@(IsPinDisabled(note))"
|
||||||
Color="Color.Error">
|
Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
|
||||||
Delete
|
Class="flex-shrink-0" />
|
||||||
</MudButton>
|
}
|
||||||
|
</MudStack>
|
||||||
|
</TitleContent>
|
||||||
|
<ChildContent>
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(note.Content))
|
||||||
|
{
|
||||||
|
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
||||||
|
<div class="markdown-content">
|
||||||
|
@((MarkupString)MarkdownHelper.ToHtml(note.Content))
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||||
|
Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown")
|
||||||
|
</MudText>
|
||||||
|
<MudStack Row="true" Spacing="2">
|
||||||
|
@if (!note.IsDeleted)
|
||||||
|
{
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.History"
|
||||||
|
OnClick="() => OpenHistoryDialog(note.Id)"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Size="Size.Small">
|
||||||
|
History
|
||||||
|
</MudButton>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||||
|
OnClick="() => OpenEditDialog(note)"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Primary">
|
||||||
|
Edit
|
||||||
|
</MudButton>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Outlined.Delete"
|
||||||
|
OnClick="() => DeleteNote(note)"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Error">
|
||||||
|
Delete
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Restore"
|
||||||
|
OnClick="() => RestoreNote(note)"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Success">
|
||||||
|
Restore
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudStack>
|
</ChildContent>
|
||||||
</MudStack>
|
</MudExpansionPanel>
|
||||||
</MudExpansionPanel>
|
}
|
||||||
}
|
</MudExpansionPanels>
|
||||||
</MudExpansionPanels>
|
</ChildContent>
|
||||||
|
</ClientSidePagination>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
// Constants
|
||||||
|
private const int MaxPreviewLength = 60;
|
||||||
|
private const int MaxPinnedNotes = 3;
|
||||||
|
private const int DefaultPageSize = 25;
|
||||||
|
|
||||||
private List<Note> _notes = [];
|
private List<Note> _notes = [];
|
||||||
private bool _isLoading = true;
|
private bool _isLoading = true;
|
||||||
|
private bool _showRemoved = false;
|
||||||
|
private int _pinnedCount = 0;
|
||||||
private CancellationTokenSource? _cancellationTokenSource;
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
private bool _isDisposed = false;
|
private bool _isDisposed = false;
|
||||||
|
private int? _selectedNoteId;
|
||||||
|
private Dictionary<int, bool> _expandedNotes = new();
|
||||||
|
|
||||||
|
|
||||||
|
private static DialogOptions GetDefaultDialogOptions() => new()
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery]
|
||||||
|
private int? Id { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
@@ -93,9 +176,34 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
_selectedNoteId = Id;
|
||||||
await LoadNotes();
|
await LoadNotes();
|
||||||
|
|
||||||
|
// Clear the query parameter after loading
|
||||||
|
if (Id.HasValue)
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("/notes", replace: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool GetNoteExpanded(int noteId)
|
||||||
|
{
|
||||||
|
if (_expandedNotes.ContainsKey(noteId))
|
||||||
|
{
|
||||||
|
return _expandedNotes[noteId];
|
||||||
|
}
|
||||||
|
return _selectedNoteId.HasValue && noteId == _selectedNoteId.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPanelExpandedChanged(int noteId, bool expanded)
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
_expandedNotes[noteId] = expanded;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task LoadNotes()
|
private async Task LoadNotes()
|
||||||
{
|
{
|
||||||
if (_isDisposed) return;
|
if (_isDisposed) return;
|
||||||
@@ -104,7 +212,26 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
_notes = (await NotesService.GetNotesAsync()).ToList();
|
|
||||||
|
if (_showRemoved)
|
||||||
|
{
|
||||||
|
_notes = (await NotesService.GetDeletedNotesAsync()).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_notes = (await NotesService.GetNotesAsync(false)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pinned count cache
|
||||||
|
_pinnedCount = _notes.Count(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted);
|
||||||
|
|
||||||
|
// Clean up expanded state for notes that no longer exist
|
||||||
|
var existingNoteIds = _notes.Select(n => n.Id).ToHashSet();
|
||||||
|
var notesToRemove = _expandedNotes.Keys.Where(id => !existingNoteIds.Contains(id)).ToList();
|
||||||
|
foreach (var id in notesToRemove)
|
||||||
|
{
|
||||||
|
_expandedNotes.Remove(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
@@ -131,6 +258,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetNoteHeaderText(Note note, bool isExpanded)
|
||||||
|
{
|
||||||
|
// When expanded, only show title (no preview)
|
||||||
|
if (isExpanded || string.IsNullOrWhiteSpace(note.Content))
|
||||||
|
{
|
||||||
|
return note.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview = GetPreview(note.Content);
|
||||||
|
if (string.IsNullOrWhiteSpace(preview))
|
||||||
|
{
|
||||||
|
return note.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{note.Title} — {preview}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPreview(string? content)
|
||||||
|
{
|
||||||
|
return MarkdownHelper.StripMarkdownPreview(content, MaxPreviewLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleRemovedFilter()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
_showRemoved = !_showRemoved;
|
||||||
|
await LoadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OpenCreateDialog()
|
private async Task OpenCreateDialog()
|
||||||
{
|
{
|
||||||
if (_isDisposed) return;
|
if (_isDisposed) return;
|
||||||
@@ -141,14 +298,7 @@
|
|||||||
["IsEdit"] = false
|
["IsEdit"] = false
|
||||||
};
|
};
|
||||||
|
|
||||||
var options = new DialogOptions
|
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
|
||||||
{
|
|
||||||
MaxWidth = MaxWidth.Large,
|
|
||||||
FullWidth = true,
|
|
||||||
CloseButton = true
|
|
||||||
};
|
|
||||||
|
|
||||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, options);
|
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
|
|
||||||
if (!result.Canceled && !_isDisposed)
|
if (!result.Canceled && !_isDisposed)
|
||||||
@@ -169,7 +319,7 @@
|
|||||||
|
|
||||||
var options = new DialogOptions
|
var options = new DialogOptions
|
||||||
{
|
{
|
||||||
MaxWidth = MaxWidth.Large,
|
MaxWidth = MaxWidth.Medium,
|
||||||
FullWidth = true,
|
FullWidth = true,
|
||||||
CloseButton = true
|
CloseButton = true
|
||||||
};
|
};
|
||||||
@@ -192,14 +342,61 @@
|
|||||||
["NoteId"] = noteId
|
["NoteId"] = noteId
|
||||||
};
|
};
|
||||||
|
|
||||||
var options = new DialogOptions
|
await DialogService.ShowAsync<NoteHistoryDialog>("Note History", parameters, GetDefaultDialogOptions());
|
||||||
{
|
}
|
||||||
MaxWidth = MaxWidth.Large,
|
|
||||||
FullWidth = true,
|
|
||||||
CloseButton = true
|
|
||||||
};
|
|
||||||
|
|
||||||
await DialogService.ShowAsync<NoteHistoryDialog>("Note History", parameters, options);
|
private async Task TogglePin(Note note)
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await NotesService.TogglePinNoteAsync(note.Id);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
// Reload to get updated state before showing message
|
||||||
|
await LoadNotes();
|
||||||
|
var updatedNote = _notes.FirstOrDefault(n => n.Id == note.Id);
|
||||||
|
if (updatedNote != null)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Note '{updatedNote.Title}' {(updatedNote.IsPinned ? "pinned" : "unpinned")}", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error toggling pin: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPinDisabled(Note note)
|
||||||
|
{
|
||||||
|
// Can always unpin
|
||||||
|
if (note.IsPinned)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Can't pin page notes
|
||||||
|
if (NoteNamingService.IsPageNote(note.Title))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Can't pin deleted notes
|
||||||
|
if (note.IsDeleted)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Check if already at max pinned notes (using cached count)
|
||||||
|
return _pinnedCount >= MaxPinnedNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteNote(Note note)
|
private async Task DeleteNote(Note note)
|
||||||
@@ -212,7 +409,7 @@
|
|||||||
|
|
||||||
var result = await DialogService.ShowMessageBox(
|
var result = await DialogService.ShowMessageBox(
|
||||||
"Delete Note",
|
"Delete Note",
|
||||||
(MarkupString)$"Are you sure you want to delete <b>{note.Title}</b>? This cannot be undone.",
|
(MarkupString)$"Are you sure you want to delete <b>{note.Title}</b>? You can restore it later from the 'Show Removed' view.",
|
||||||
yesText: "Yes",
|
yesText: "Yes",
|
||||||
noText: "Cancel");
|
noText: "Cancel");
|
||||||
|
|
||||||
@@ -246,6 +443,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RestoreNote(Note note)
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await NotesService.RestoreNoteAsync(note.Id);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Note '{note.Title}' restored", Severity.Success);
|
||||||
|
await LoadNotes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error restoring note: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (!_isDisposed)
|
if (!_isDisposed)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
|
||||||
|
<MudPaper Elevation="0" Class="pa-1" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudTooltip Text="@AddTooltip">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Add"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="HandleAdd"
|
||||||
|
Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
<MudText Typo="Typo.body2" Style="flex: 1; text-align: center;">@Label</MudText>
|
||||||
|
<MudTooltip Text="@RemoveTooltip">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Remove"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Error"
|
||||||
|
OnClick="HandleRemove"
|
||||||
|
Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public required string Label { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnAdd { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnRemove { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? AddTooltip { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? RemoveTooltip { get; set; }
|
||||||
|
|
||||||
|
private async Task HandleAdd()
|
||||||
|
{
|
||||||
|
if (OnAdd.HasDelegate)
|
||||||
|
{
|
||||||
|
await OnAdd.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRemove()
|
||||||
|
{
|
||||||
|
if (OnRemove.HasDelegate)
|
||||||
|
{
|
||||||
|
await OnRemove.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
@typeparam T
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
@ChildContent(_paginatedItems)
|
||||||
|
|
||||||
|
@if (_totalPages > 1 || _currentPageSize != DefaultPageSize)
|
||||||
|
{
|
||||||
|
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mt-4">
|
||||||
|
@if (ShowPageSizeSelector)
|
||||||
|
{
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudText Typo="Typo.body2">Items per page:</MudText>
|
||||||
|
<MudSelect T="int" Value="@_currentPageSize" ValueChanged="OnPageSizeChanged" Variant="Variant.Outlined" Dense="true" Style="width: 100px;">
|
||||||
|
@foreach (var option in PageSizeOptions)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@option">@option</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_totalPages > 1)
|
||||||
|
{
|
||||||
|
<MudPagination Count="@_totalPages"
|
||||||
|
Selected="@(_currentPage + 1)"
|
||||||
|
SelectedChanged="OnPageChanged"
|
||||||
|
ShowFirstButton="true"
|
||||||
|
ShowLastButton="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (ShowItemCount)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||||
|
Showing @_displayStart to @_displayEnd of @_totalItems
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<T> _paginatedItems = new();
|
||||||
|
private int _currentPage = 0;
|
||||||
|
private int _currentPageSize;
|
||||||
|
private int _totalItems = 0;
|
||||||
|
private int _totalPages = 0;
|
||||||
|
private int _displayStart = 0;
|
||||||
|
private int _displayEnd = 0;
|
||||||
|
private IEnumerable<T>? _previousItems;
|
||||||
|
|
||||||
|
[Parameter] public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
|
||||||
|
[Parameter] public int DefaultPageSize { get; set; } = 25;
|
||||||
|
[Parameter] public int[] PageSizeOptions { get; set; } = new[] { 10, 25, 50, 100 };
|
||||||
|
[Parameter] public bool ShowPageSizeSelector { get; set; } = true;
|
||||||
|
[Parameter] public bool ShowItemCount { get; set; } = true;
|
||||||
|
[Parameter] public RenderFragment<IEnumerable<T>> ChildContent { get; set; } = null!;
|
||||||
|
|
||||||
|
public IEnumerable<T> PaginatedItems => _paginatedItems;
|
||||||
|
public int CurrentPage => _currentPage;
|
||||||
|
public int CurrentPageSize => _currentPageSize;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_currentPageSize = DefaultPageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// Check if items collection has changed (reference or count)
|
||||||
|
var itemsChanged = _previousItems != Items ||
|
||||||
|
(_previousItems?.Count() ?? 0) != (Items?.Count() ?? 0);
|
||||||
|
|
||||||
|
if (itemsChanged)
|
||||||
|
{
|
||||||
|
_previousItems = Items?.ToList();
|
||||||
|
_currentPage = 0; // Reset to first page when items change
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdatePagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPageSizeChanged(int newPageSize)
|
||||||
|
{
|
||||||
|
if (_currentPageSize != newPageSize)
|
||||||
|
{
|
||||||
|
_currentPageSize = newPageSize;
|
||||||
|
_currentPage = 0; // Reset to first page when page size changes
|
||||||
|
UpdatePagination();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePagination()
|
||||||
|
{
|
||||||
|
var itemsList = Items?.ToList() ?? new List<T>();
|
||||||
|
_totalItems = itemsList.Count;
|
||||||
|
_totalPages = _totalItems > 0 ? (int)Math.Ceiling((double)_totalItems / _currentPageSize) : 0;
|
||||||
|
|
||||||
|
// Ensure current page is valid
|
||||||
|
if (_totalPages > 0 && _currentPage >= _totalPages)
|
||||||
|
{
|
||||||
|
_currentPage = _totalPages - 1;
|
||||||
|
}
|
||||||
|
else if (_currentPage < 0)
|
||||||
|
{
|
||||||
|
_currentPage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate paginated items
|
||||||
|
var startIndex = _currentPage * _currentPageSize;
|
||||||
|
_paginatedItems = itemsList.Skip(startIndex).Take(_currentPageSize).ToList();
|
||||||
|
|
||||||
|
// Calculate display range
|
||||||
|
_displayStart = _totalItems > 0 ? startIndex + 1 : 0;
|
||||||
|
_displayEnd = Math.Min(startIndex + _currentPageSize, _totalItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPageChanged(int newPage)
|
||||||
|
{
|
||||||
|
_currentPage = newPage - 1; // MudPagination is 1-based, we use 0-based
|
||||||
|
UpdatePagination();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
@namespace WebApp.Components.Shared.Components
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
|
||||||
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center"
|
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center"
|
||||||
Style="position: relative; display: inline-flex;"
|
Style="position: relative; display: inline-flex;"
|
||||||
Class="@WrapperClass"
|
Class="@WrapperClass"
|
||||||
@onmouseenter="@(() => _isHovered = true)"
|
@onmouseenter="@(() => _isHovered = true)"
|
||||||
@onmouseleave="@(() => _isHovered = false)">
|
@onmouseleave="@(() => _isHovered = false)"
|
||||||
|
@ontouchstart="@HandleTouchStart">
|
||||||
<MudChip T="string"
|
<MudChip T="string"
|
||||||
Size="@Size"
|
Size="@Size"
|
||||||
Color="@Color"
|
Color="@Color"
|
||||||
@@ -13,9 +15,22 @@
|
|||||||
Class="@Class">
|
Class="@Class">
|
||||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
@if (_isHovered && ControlContent != null)
|
@if (ControlContent != null)
|
||||||
{
|
{
|
||||||
@ControlContent
|
@* Show on touch for mobile/touch devices (below Md) *@
|
||||||
|
<MudHidden Breakpoint="Breakpoint.MdAndUp">
|
||||||
|
@if (_isTouched || AlwaysShowControls)
|
||||||
|
{
|
||||||
|
@ControlContent
|
||||||
|
}
|
||||||
|
</MudHidden>
|
||||||
|
@* Show on hover for desktop devices (MdAndUp) *@
|
||||||
|
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
|
||||||
|
@if (_isHovered || AlwaysShowControls)
|
||||||
|
{
|
||||||
|
@ControlContent
|
||||||
|
}
|
||||||
|
</MudHidden>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudChip>
|
</MudChip>
|
||||||
@@ -46,5 +61,15 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string? WrapperClass { get; set; }
|
public string? WrapperClass { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool AlwaysShowControls { get; set; } = false;
|
||||||
|
|
||||||
private bool _isHovered = false;
|
private bool _isHovered = false;
|
||||||
|
private bool _isTouched = false;
|
||||||
|
|
||||||
|
private void HandleTouchStart(TouchEventArgs e)
|
||||||
|
{
|
||||||
|
_isTouched = !_isTouched;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
@* Reuse dialog options pattern - could be extracted if used in more places *@
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
<MudCard Elevation="4" Class="@($"pa-4 {MarkdownHelper.GetNoteColorClass(Note.Id)}".Trim())" Style="cursor: pointer; height: 100%;" @onclick="OpenNoteDialog">
|
||||||
|
<MudCardContent>
|
||||||
|
<div class="d-flex align-center mb-2">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Note" Size="Size.Medium" Class="mr-2" />
|
||||||
|
<MudText Typo="Typo.h6" Class="flex-grow-1">@Note.Title</MudText>
|
||||||
|
@if (Note.IsPinned)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.PushPin" Size="Size.Small" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MudDivider Class="mb-3" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(PreviewText))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Style="max-height: 100px; overflow: hidden; text-overflow: ellipsis;">
|
||||||
|
@PreviewText
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
|
||||||
|
No content
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudCardContent>
|
||||||
|
</MudCard>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public Note Note { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnNoteChanged { get; set; }
|
||||||
|
|
||||||
|
private const int CardPreviewMaxLength = 150;
|
||||||
|
private string PreviewText => MarkdownHelper.StripMarkdownPreview(Note.Content, CardPreviewMaxLength);
|
||||||
|
|
||||||
|
private async Task OpenNoteDialog()
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["NoteId"] = Note.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<NoteViewDialog>("Note", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
// If dialog was closed and result indicates a change (e.g., note deleted or unpinned)
|
||||||
|
if (result != null && !result.Canceled)
|
||||||
|
{
|
||||||
|
await OnNoteChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(NoteId)">
|
||||||
|
<DialogContent>
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.h6">@_note.Title</MudText>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_note.Content))
|
||||||
|
{
|
||||||
|
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
||||||
|
<div class="markdown-content">
|
||||||
|
@((MarkupString)MarkdownHelper.ToHtml(_note.Content))
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
|
||||||
|
No content yet. Click Edit to add content.
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
OnClick="NavigateToNotes"
|
||||||
|
Disabled="@_isLoading">
|
||||||
|
Edit in Notes
|
||||||
|
</MudButton>
|
||||||
|
<MudButton OnClick="Cancel">Close</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int NoteId { get; set; }
|
||||||
|
|
||||||
|
private Note _note = null!;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadNote();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadNote()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
var note = await NotesService.GetNoteAsync(NoteId);
|
||||||
|
|
||||||
|
if (note != null)
|
||||||
|
{
|
||||||
|
_note = new Note
|
||||||
|
{
|
||||||
|
Id = note.Id,
|
||||||
|
Title = note.Title,
|
||||||
|
Content = note.Content ?? "",
|
||||||
|
IsPinned = note.IsPinned
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
Snackbar.Add("Note not found", Severity.Error);
|
||||||
|
MudDialog.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading note: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateToNotes()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
MudDialog.Close();
|
||||||
|
NavigationManager.NavigateTo($"/notes?id={_note.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
MudDialog.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<div @ref="_anchorElement"
|
||||||
|
@onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
|
||||||
|
@onmouseleave="@(() => _popoverOpen = false)"
|
||||||
|
style="display: inline-block;">
|
||||||
|
<MudButton StartIcon="@IconValue"
|
||||||
|
OnClick="OpenDialog"
|
||||||
|
Variant="@Variant"
|
||||||
|
Size="@Size"
|
||||||
|
Color="@ButtonColor"
|
||||||
|
Tooltip="@TooltipText"
|
||||||
|
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
|
||||||
|
@if (!string.IsNullOrEmpty(ButtonText))
|
||||||
|
{
|
||||||
|
@ButtonText
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
<MudPopover @bind-Open="_popoverOpen"
|
||||||
|
AnchorOrigin="Origin.BottomCenter"
|
||||||
|
TransformOrigin="Origin.TopCenter"
|
||||||
|
Elevation="8"
|
||||||
|
Anchor="@_anchorElement">
|
||||||
|
<ChildContent>
|
||||||
|
@if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent))
|
||||||
|
{
|
||||||
|
<MudPaper Elevation="0"
|
||||||
|
Class="pa-3"
|
||||||
|
Style="max-width: 500px; max-height: 400px; overflow-y: auto;"
|
||||||
|
@onmouseenter="@(() => _popoverOpen = true)"
|
||||||
|
@onmouseleave="@(() => _popoverOpen = false)">
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="mb-2">@PageIdentifier</MudText>
|
||||||
|
<MudDivider Class="my-2" />
|
||||||
|
<div class="markdown-content" style="max-height: 350px; overflow-y: auto;">
|
||||||
|
@((MarkupString)MarkdownHelper.ToHtml(_noteContent))
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</MudPopover>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string PageIdentifier { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Variant Variant { get; set; } = Variant.Outlined;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Size Size { get; set; } = Size.Medium;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? ButtonText { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? Tooltip { get; set; }
|
||||||
|
|
||||||
|
private string IconValue => Icon ?? Icons.Material.Filled.Note;
|
||||||
|
private string TooltipText => Tooltip ?? $"Page notes for {PageIdentifier}";
|
||||||
|
private bool _hasContent = false;
|
||||||
|
private int _noteId = 0;
|
||||||
|
private string _noteContent = string.Empty;
|
||||||
|
private bool _popoverOpen = false;
|
||||||
|
private ElementReference _anchorElement;
|
||||||
|
private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default);
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await CheckNoteContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckNoteContent()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var note = await NotesService.GetPageNoteAsync(PageIdentifier);
|
||||||
|
_hasContent = note != null && !string.IsNullOrWhiteSpace(note.Content);
|
||||||
|
_noteId = note?.Id ?? 0;
|
||||||
|
_noteContent = note?.Content ?? string.Empty;
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Error checking note - ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenDialog()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["PageIdentifier"] = PageIdentifier
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<PageNoteDialog>($"Page Notes: {PageIdentifier}", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
// Always refresh content indicator after dialog closes
|
||||||
|
// (content may have been saved even if dialog was closed with Cancel)
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
await CheckNoteContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Error opening dialog - could show snackbar if we had access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
@using Core.Services
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject INoteNamingService NoteNamingService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject MarkdownTablePasteService MarkdownTablePasteService
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
|
||||||
|
<DialogContent>
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else if (_isEditMode)
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText>
|
||||||
|
<MarkdownEditor Value="@_note.Content"
|
||||||
|
ValueChanged="@((string? value) => _note.Content = value)"
|
||||||
|
Placeholder="Enter your markdown content here..."
|
||||||
|
AutoSaveEnabled="false"
|
||||||
|
NativeSpellChecker="false" />
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.h6">@DisplayTitle</MudText>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_note.Content))
|
||||||
|
{
|
||||||
|
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
||||||
|
<div class="markdown-content">
|
||||||
|
@((MarkupString)MarkdownHelper.ToHtml(_note.Content))
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
|
||||||
|
No content yet. Click Edit to add content.
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
@if (_isEditMode)
|
||||||
|
{
|
||||||
|
<MudButton OnClick="Cancel" Disabled="@_isLoading">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@_isLoading">Save</MudButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
OnClick="EnterEditMode"
|
||||||
|
Disabled="@_isLoading">
|
||||||
|
Edit
|
||||||
|
</MudButton>
|
||||||
|
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||||
|
}
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string PageIdentifier { get; set; } = null!;
|
||||||
|
|
||||||
|
private Note _note = null!;
|
||||||
|
private string _pageNoteTitle = null!;
|
||||||
|
private string DisplayTitle => $"{PageIdentifier} Note";
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private bool _isEdit = false;
|
||||||
|
private bool _isEditMode = false;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
private bool _pasteMarkdownInitialized = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
_pageNoteTitle = NoteNamingService.GetPageNoteTitle(PageIdentifier);
|
||||||
|
_note = new Note
|
||||||
|
{
|
||||||
|
Title = _pageNoteTitle,
|
||||||
|
Content = ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadPageNote();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && _isEditMode && !_pasteMarkdownInitialized)
|
||||||
|
{
|
||||||
|
// Initialize paste-markdown after first render if already in edit mode
|
||||||
|
await Task.Delay(150); // Wait for EasyMDE to initialize
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
await MarkdownTablePasteService.InitializeAsync();
|
||||||
|
_pasteMarkdownInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPageNote()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingNote = await NotesService.GetPageNoteAsync(PageIdentifier);
|
||||||
|
|
||||||
|
if (existingNote != null)
|
||||||
|
{
|
||||||
|
_note = new Note
|
||||||
|
{
|
||||||
|
Id = existingNote.Id,
|
||||||
|
Title = existingNote.Title,
|
||||||
|
Content = existingNote.Content ?? ""
|
||||||
|
};
|
||||||
|
_isEdit = true;
|
||||||
|
// If note has no content, open in edit mode
|
||||||
|
if (string.IsNullOrWhiteSpace(_note.Content))
|
||||||
|
{
|
||||||
|
_isEditMode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_note = new Note
|
||||||
|
{
|
||||||
|
Title = _pageNoteTitle,
|
||||||
|
Content = ""
|
||||||
|
};
|
||||||
|
_isEdit = false;
|
||||||
|
// New note - open in edit mode
|
||||||
|
_isEditMode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading page note: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_isEdit)
|
||||||
|
{
|
||||||
|
await NotesService.UpdateNoteAsync(_note);
|
||||||
|
Snackbar.Add("Page note updated successfully", Severity.Success);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// CreateNoteAsync returns the note with the ID populated
|
||||||
|
var createdNote = await NotesService.CreateNoteAsync(_note);
|
||||||
|
// Update _note with the returned note to get the ID immediately
|
||||||
|
_note = createdNote;
|
||||||
|
_isEdit = true; // Mark as existing note now
|
||||||
|
Snackbar.Add("Page note created successfully", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
// After saving, switch back to view mode and reload the note to ensure we have latest data
|
||||||
|
_isEditMode = false;
|
||||||
|
await LoadPageNote();
|
||||||
|
// Ensure dialog re-renders with updated color class after note ID is set
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error saving page note: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnterEditMode()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
_isEditMode = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// Initialize paste-markdown after editor is rendered
|
||||||
|
if (!_pasteMarkdownInitialized)
|
||||||
|
{
|
||||||
|
await Task.Delay(150); // Wait for EasyMDE to initialize
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
await MarkdownTablePasteService.InitializeAsync();
|
||||||
|
_pasteMarkdownInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
if (_isEditMode)
|
||||||
|
{
|
||||||
|
// If in edit mode, cancel closes the dialog (discarding changes)
|
||||||
|
MudDialog.Close(DialogResult.Cancel());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MudDialog.Close(DialogResult.Cancel());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Heron.MudCalendar;
|
||||||
|
|
||||||
|
namespace WebApp.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type discriminator for calendar item types.
|
||||||
|
/// </summary>
|
||||||
|
public enum CalendarItemType
|
||||||
|
{
|
||||||
|
Event,
|
||||||
|
Meeting
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrapper class for calendar items that can hold either a CalendarEventItem or CalendarMeetingItem.
|
||||||
|
/// This allows MudCalendar to display mixed item types while maintaining type safety.
|
||||||
|
/// </summary>
|
||||||
|
public class CalendarItemWrapper : CalendarItem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of calendar item this wrapper contains.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarItemType ItemType { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the wrapped CalendarEventItem if ItemType is Event, otherwise null.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarEventItem? EventItem { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the wrapped CalendarMeetingItem if ItemType is Meeting, otherwise null.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarMeetingItem? MeetingItem { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parameterless constructor required by Heron.MudCalendar component.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarItemWrapper()
|
||||||
|
{
|
||||||
|
// Initialize base class properties to avoid null reference issues
|
||||||
|
Text = string.Empty;
|
||||||
|
Start = DateTime.MinValue;
|
||||||
|
End = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a wrapper for a CalendarEventItem.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarItemWrapper(CalendarEventItem eventItem)
|
||||||
|
{
|
||||||
|
ItemType = CalendarItemType.Event;
|
||||||
|
EventItem = eventItem;
|
||||||
|
// Delegate properties to wrapped item
|
||||||
|
Text = eventItem.Text;
|
||||||
|
Start = eventItem.Start;
|
||||||
|
End = eventItem.End;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a wrapper for a CalendarMeetingItem.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarItemWrapper(CalendarMeetingItem meetingItem)
|
||||||
|
{
|
||||||
|
ItemType = CalendarItemType.Meeting;
|
||||||
|
MeetingItem = meetingItem;
|
||||||
|
// Delegate properties to wrapped item
|
||||||
|
Text = meetingItem.Text;
|
||||||
|
Start = meetingItem.Start;
|
||||||
|
End = meetingItem.End;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Heron.MudCalendar;
|
||||||
|
|
||||||
|
namespace WebApp.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calendar meeting item model for Heron.MudCalendar component.
|
||||||
|
/// Maps from TeamMeetingHistory entity to calendar event format.
|
||||||
|
/// </summary>
|
||||||
|
public class CalendarMeetingItem : CalendarItem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the original TeamMeetingHistory data.
|
||||||
|
/// </summary>
|
||||||
|
public TeamMeetingHistory? MeetingHistoryData { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parameterless constructor required by Heron.MudCalendar component.
|
||||||
|
/// </summary>
|
||||||
|
public CalendarMeetingItem()
|
||||||
|
{
|
||||||
|
// Initialize base class properties to avoid null reference issues
|
||||||
|
Text = string.Empty;
|
||||||
|
Start = DateTime.MinValue;
|
||||||
|
End = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CalendarMeetingItem(TeamMeetingHistory meetingHistory)
|
||||||
|
{
|
||||||
|
MeetingHistoryData = meetingHistory;
|
||||||
|
// Set base class properties that the calendar component uses
|
||||||
|
Text = "Team Meeting";
|
||||||
|
// Set start to 9:00 AM on the meeting date
|
||||||
|
Start = meetingHistory.MeetingDate.Date.AddHours(9);
|
||||||
|
// Set end to 5:00 PM on the meeting date (5:01 PM to ensure it displays until 5pm if end is exclusive)
|
||||||
|
End = meetingHistory.MeetingDate.Date.AddHours(17).AddMinutes(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,117 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace WebApp.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the state of the meeting schedule for dirty/clean tracking and persistence.
|
||||||
|
/// </summary>
|
||||||
|
public class MeetingScheduleState : IEquatable<MeetingScheduleState>
|
||||||
|
{
|
||||||
|
public HashSet<int> ScheduledTeamIds { get; set; } = [];
|
||||||
|
public HashSet<int> AbsentStudentIds { get; set; } = [];
|
||||||
|
public int TimeSlotCount { get; set; }
|
||||||
|
public HashSet<int> ExtendedTeamIds { get; set; } = [];
|
||||||
|
public HashSet<(int teamId, int timeSlotIndex, int studentId)> ExcludedStudents { get; set; } = [];
|
||||||
|
|
||||||
|
public bool Equals(MeetingScheduleState? other)
|
||||||
|
{
|
||||||
|
if (other == null) return false;
|
||||||
|
return ScheduledTeamIds.SetEquals(other.ScheduledTeamIds) &&
|
||||||
|
AbsentStudentIds.SetEquals(other.AbsentStudentIds) &&
|
||||||
|
TimeSlotCount == other.TimeSlotCount &&
|
||||||
|
ExtendedTeamIds.SetEquals(other.ExtendedTeamIds) &&
|
||||||
|
ExcludedStudents.SetEquals(other.ExcludedStudents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object? obj) => Equals(obj as MeetingScheduleState);
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(ScheduledTeamIds.Count);
|
||||||
|
foreach (var id in ScheduledTeamIds.OrderBy(x => x))
|
||||||
|
hash.Add(id);
|
||||||
|
hash.Add(AbsentStudentIds.Count);
|
||||||
|
foreach (var id in AbsentStudentIds.OrderBy(x => x))
|
||||||
|
hash.Add(id);
|
||||||
|
hash.Add(TimeSlotCount);
|
||||||
|
hash.Add(ExtendedTeamIds.Count);
|
||||||
|
foreach (var id in ExtendedTeamIds.OrderBy(x => x))
|
||||||
|
hash.Add(id);
|
||||||
|
hash.Add(ExcludedStudents.Count);
|
||||||
|
foreach (var key in ExcludedStudents.OrderBy(x => x))
|
||||||
|
hash.Add(key);
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MeetingScheduleState FromCurrent(
|
||||||
|
IEnumerable<Team> scheduledTeams,
|
||||||
|
IEnumerable<Student> absentStudents,
|
||||||
|
int timeSlotCount,
|
||||||
|
IEnumerable<Team> extendedTeams,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
return new MeetingScheduleState
|
||||||
|
{
|
||||||
|
ScheduledTeamIds = scheduledTeams.Select(t => t.Id).ToHashSet(),
|
||||||
|
AbsentStudentIds = absentStudents.Select(s => s.Id).ToHashSet(),
|
||||||
|
TimeSlotCount = timeSlotCount,
|
||||||
|
ExtendedTeamIds = extendedTeams.Select(t => t.Id).ToHashSet(),
|
||||||
|
ExcludedStudents = excludedStudents.Keys
|
||||||
|
.Where(k => excludedStudents[k])
|
||||||
|
.ToHashSet()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<MeetingScheduleState?> FromLocalStorage(
|
||||||
|
WebApp.LocalStorageService localStorage,
|
||||||
|
Team[] allTeams,
|
||||||
|
Student[] allStudents)
|
||||||
|
{
|
||||||
|
// Load scheduled teams
|
||||||
|
var scheduledTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ScheduledTeams");
|
||||||
|
var absentStudentIds = await localStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents");
|
||||||
|
var timeSlotCount = await localStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2);
|
||||||
|
var extendedTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ExtendedTeams");
|
||||||
|
var exclusions = await localStorage.GetJsonAsync<ExcludedStudent[]>("MeetingSchedule_ExcludedStudents");
|
||||||
|
|
||||||
|
// If no state exists, return null
|
||||||
|
if (scheduledTeamIds.Length == 0 && absentStudentIds.Length == 0 &&
|
||||||
|
timeSlotCount == 2 && extendedTeamIds.Length == 0 &&
|
||||||
|
(exclusions == null || exclusions.Length == 0))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var excludedStudentsSet = new HashSet<(int teamId, int timeSlotIndex, int studentId)>();
|
||||||
|
if (exclusions != null && exclusions.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var exclusion in exclusions)
|
||||||
|
{
|
||||||
|
excludedStudentsSet.Add((exclusion.TeamId, exclusion.TimeSlotIndex, exclusion.StudentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MeetingScheduleState
|
||||||
|
{
|
||||||
|
ScheduledTeamIds = scheduledTeamIds.ToHashSet(),
|
||||||
|
AbsentStudentIds = absentStudentIds.ToHashSet(),
|
||||||
|
TimeSlotCount = timeSlotCount > 0 ? timeSlotCount : 2,
|
||||||
|
ExtendedTeamIds = extendedTeamIds.ToHashSet(),
|
||||||
|
ExcludedStudents = excludedStudentsSet
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveToLocalStorage(WebApp.LocalStorageService localStorage)
|
||||||
|
{
|
||||||
|
await localStorage.SetIntArrayAsync("MeetingSchedule_ScheduledTeams", ScheduledTeamIds.ToArray());
|
||||||
|
await localStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", AbsentStudentIds.ToArray());
|
||||||
|
await localStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", TimeSlotCount);
|
||||||
|
await localStorage.SetIntArrayAsync("MeetingSchedule_ExtendedTeams", ExtendedTeamIds.ToArray());
|
||||||
|
|
||||||
|
var exclusions = ExcludedStudents.Select(e => new ExcludedStudent(e.teamId, e.timeSlotIndex, e.studentId)).ToArray();
|
||||||
|
await localStorage.SetJsonAsync("MeetingSchedule_ExcludedStudents", exclusions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
];
|
||||||
|
}
|
||||||
+19
-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_");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +194,16 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
|
|||||||
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
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.ICalendarService, WebApp.Services.CalendarService>();
|
||||||
|
builder.Services.AddScoped<Core.Services.INoteNamingService, Core.Services.NoteNamingService>();
|
||||||
|
builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
|
||||||
|
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
|
||||||
|
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)
|
// State container for maintaining state per user connection (Blazor Server)
|
||||||
builder.Services.AddScoped<StateContainer>();
|
builder.Services.AddScoped<StateContainer>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Core.Calculation;
|
||||||
|
using Core.Entities;
|
||||||
|
using Core.Utility;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for formatting meeting schedule data for clipboard operations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMeetingScheduleClipboardService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Formats the entire schedule solution as text for clipboard.
|
||||||
|
/// </summary>
|
||||||
|
string FormatScheduleForClipboard(
|
||||||
|
TeamSchedulerSolution solution,
|
||||||
|
Team[] allTeams,
|
||||||
|
IEnumerable<Student> absentStudents,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
|
||||||
|
Func<string, int> getTimeSlotIndex);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for loading meeting schedule data from the database.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMeetingScheduleDataService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads all teams with their events and students.
|
||||||
|
/// </summary>
|
||||||
|
Task<Team[]> LoadTeamsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads all students with their teams, event rankings, and related data.
|
||||||
|
/// </summary>
|
||||||
|
Task<Student[]> LoadStudentsAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing meeting schedule state persistence in localStorage.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMeetingScheduleStateService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads scheduled teams from localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Team>> LoadScheduledTeamsAsync(Team[] allTeams);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves scheduled teams to localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task SaveScheduledTeamsAsync(IEnumerable<Team> scheduledTeams);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads absent students from localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Student>> LoadAbsentStudentsAsync(Student[] allStudents);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves absent students to localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task SaveAbsentStudentsAsync(IEnumerable<Student> absentStudents);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads time slot count from localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> LoadTimeSlotCountAsync(int defaultValue = 2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves time slot count to localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task SaveTimeSlotCountAsync(int timeSlotCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads extended teams from localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Team>> LoadExtendedTeamsAsync(Team[] allTeams);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves extended teams to localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task SaveExtendedTeamsAsync(IEnumerable<Team> extendedTeams);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads excluded students from localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task<Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>> LoadExcludedStudentsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves excluded students to localStorage.
|
||||||
|
/// </summary>
|
||||||
|
Task SaveExcludedStudentsAsync(Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents);
|
||||||
|
}
|
||||||
@@ -5,15 +5,23 @@ namespace WebApp.Services;
|
|||||||
public interface INotesService
|
public interface INotesService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all notes.
|
/// Gets all notes, optionally including deleted notes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IEnumerable<Note>> GetNotesAsync();
|
/// <param name="includeDeleted">If true, includes soft-deleted notes. Defaults to false.</param>
|
||||||
|
Task<IEnumerable<Note>> GetNotesAsync(bool includeDeleted = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a single note by ID.
|
/// Gets a single note by ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Note?> GetNoteAsync(int id);
|
Task<Note?> GetNoteAsync(int id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a page-specific note by page identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pageIdentifier">The page identifier (e.g., "Teams", "Registration")</param>
|
||||||
|
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
|
||||||
|
Task<Note?> GetPageNoteAsync(string pageIdentifier);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all history entries for a note.
|
/// Gets all history entries for a note.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -33,4 +41,24 @@ public interface INotesService
|
|||||||
/// Deletes a note and creates a deletion history entry.
|
/// Deletes a note and creates a deletion history entry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task DeleteNoteAsync(int id);
|
Task DeleteNoteAsync(int id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets up to 3 pinned notes, excluding page notes and deleted notes, ordered by most recently updated.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Note>> GetPinnedNotesAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggles the pin status of a note, enforcing the 3-note limit.
|
||||||
|
/// </summary>
|
||||||
|
Task TogglePinNoteAsync(int noteId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all soft-deleted notes.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Note>> GetDeletedNotesAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores a soft-deleted note.
|
||||||
|
/// </summary>
|
||||||
|
Task RestoreNoteAsync(int noteId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
public interface ITeamMeetingHistoryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all meeting histories within an optional date range.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="startDate">Optional start date filter</param>
|
||||||
|
/// <param name="endDate">Optional end date filter</param>
|
||||||
|
Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a specific meeting history by ID with teams and students included.
|
||||||
|
/// </summary>
|
||||||
|
Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new meeting history record.
|
||||||
|
/// </summary>
|
||||||
|
Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing meeting history record.
|
||||||
|
/// </summary>
|
||||||
|
Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a meeting history record.
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteMeetingHistoryAsync(int id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets which teams met on a specific date.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets which students were present on a specific date.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the meeting note for a specific meeting date by looking it up by title.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meetingDate">The date of the meeting</param>
|
||||||
|
/// <returns>The note if found, null otherwise</returns>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,22 @@ public static class MarkdownHelper
|
|||||||
.UseAdvancedExtensions()
|
.UseAdvancedExtensions()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
// Compiled regex patterns for stripping markdown
|
||||||
|
private static readonly Regex MarkdownLinkRegex =
|
||||||
|
new(@"\[([^\]]+)\]\([^\)]+\)", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex BoldRegex =
|
||||||
|
new(@"\*\*([^\*]+)\*\*", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex ItalicRegex =
|
||||||
|
new(@"\*([^\*]+)\*", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex InlineCodeRegex =
|
||||||
|
new(@"`([^`]+)`", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HeaderRegex =
|
||||||
|
new(@"#+\s+", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex NewlineRegex =
|
||||||
|
new(@"\n+", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HtmlTagRegex =
|
||||||
|
new(@"<[^>]+>", RegexOptions.Compiled);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts markdown text to HTML.
|
/// Converts markdown text to HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -45,4 +61,47 @@ public static class MarkdownHelper
|
|||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips markdown formatting from content and returns plain text for preview.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="content">The markdown content to strip.</param>
|
||||||
|
/// <param name="maxLength">Maximum length of the preview. If 0 or negative, no truncation is performed.</param>
|
||||||
|
/// <returns>Plain text preview with markdown formatting removed.</returns>
|
||||||
|
public static string StripMarkdownPreview(string? content, int maxLength = 0)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Strip markdown formatting for preview using compiled regex
|
||||||
|
var plainText = MarkdownLinkRegex.Replace(content, "$1"); // Replace markdown links with link text
|
||||||
|
plainText = BoldRegex.Replace(plainText, "$1"); // Remove bold
|
||||||
|
plainText = ItalicRegex.Replace(plainText, "$1"); // Remove italic
|
||||||
|
plainText = InlineCodeRegex.Replace(plainText, "$1"); // Remove inline code
|
||||||
|
plainText = HeaderRegex.Replace(plainText, ""); // Remove headers
|
||||||
|
plainText = NewlineRegex.Replace(plainText, " "); // Replace newlines with spaces
|
||||||
|
plainText = HtmlTagRegex.Replace(plainText, ""); // Remove HTML tags
|
||||||
|
|
||||||
|
plainText = plainText.Trim();
|
||||||
|
|
||||||
|
// Truncate if maxLength is specified and content exceeds it
|
||||||
|
if (maxLength > 0 && plainText.Length > maxLength)
|
||||||
|
{
|
||||||
|
return plainText.Substring(0, maxLength - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return plainText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the CSS class name for a note's color based on its ID.
|
||||||
|
/// Uses modulo 3 to cycle through 3 pastel color classes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noteId">The note ID.</param>
|
||||||
|
/// <returns>CSS class name (e.g., "note-color-0", "note-color-1", "note-color-2"), or empty string if noteId is 0.</returns>
|
||||||
|
public static string GetNoteColorClass(int noteId)
|
||||||
|
{
|
||||||
|
if (noteId == 0) return string.Empty;
|
||||||
|
return $"note-color-{noteId % 3}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for initializing paste-markdown functionality in MarkdownEditor components.
|
||||||
|
/// Enables automatic conversion of pasted spreadsheet tables (Google Sheets, Excel) to Markdown tables.
|
||||||
|
/// </summary>
|
||||||
|
public class MarkdownTablePasteService
|
||||||
|
{
|
||||||
|
private readonly IJSRuntime _jsRuntime;
|
||||||
|
private readonly ILogger<MarkdownTablePasteService> _logger;
|
||||||
|
|
||||||
|
public MarkdownTablePasteService(IJSRuntime jsRuntime, ILogger<MarkdownTablePasteService> logger)
|
||||||
|
{
|
||||||
|
_jsRuntime = jsRuntime;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes paste-markdown for a MarkdownEditor instance.
|
||||||
|
/// Should be called after the editor has been rendered and EasyMDE has initialized.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="editorId">Optional ID of the editor wrapper element. If null, will attempt to find the most recent editor.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task InitializeAsync(string? editorId = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _jsRuntime.InvokeVoidAsync("markdownTablePaste.initialize", editorId);
|
||||||
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to initialize paste-markdown for editor {EditorId}", editorId ?? "unknown");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error initializing paste-markdown for editor {EditorId}", editorId ?? "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Core.Calculation;
|
||||||
|
using Core.Entities;
|
||||||
|
using Core.Utility;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for formatting meeting schedule data for clipboard operations.
|
||||||
|
/// </summary>
|
||||||
|
public class MeetingScheduleClipboardService : IMeetingScheduleClipboardService
|
||||||
|
{
|
||||||
|
public string FormatScheduleForClipboard(
|
||||||
|
TeamSchedulerSolution solution,
|
||||||
|
Team[] allTeams,
|
||||||
|
IEnumerable<Student> absentStudents,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
|
||||||
|
Func<string, int> getTimeSlotIndex)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var timeslot in solution.TimeSlots)
|
||||||
|
{
|
||||||
|
AppendScheduledTeams(sb, timeslot, allTeams, absentStudents, excludedStudents, getTimeSlotIndex);
|
||||||
|
AppendUnscheduledStudents(sb, timeslot, solution, absentStudents);
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendScheduledTeams(
|
||||||
|
StringBuilder sb,
|
||||||
|
TeamScheduleTimeSlot timeslot,
|
||||||
|
Team[] allTeams,
|
||||||
|
IEnumerable<Student> absentStudents,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
|
||||||
|
Func<string, int> getTimeSlotIndex)
|
||||||
|
{
|
||||||
|
var timeSlotIndex = getTimeSlotIndex(timeslot.Name);
|
||||||
|
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
||||||
|
{
|
||||||
|
var teamName = scheduledTeam.ToString();
|
||||||
|
|
||||||
|
if (scheduledTeam.Event.EventFormat is EventFormat.Individual)
|
||||||
|
{
|
||||||
|
sb.Append(teamName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex, absentStudents, excludedStudents);
|
||||||
|
sb.Append($"{teamName} - {studentsList}");
|
||||||
|
}
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatStudentList(
|
||||||
|
Team team,
|
||||||
|
TeamScheduleTimeSlot timeslot,
|
||||||
|
int timeSlotIndex,
|
||||||
|
IEnumerable<Student> absentStudents,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
// Filter out excluded students for this team and time slot
|
||||||
|
var excludedStudentIds = excludedStudents.Keys
|
||||||
|
.Where(k => k.teamId == team.Id && k.timeSlotIndex == timeSlotIndex && excludedStudents[k])
|
||||||
|
.Select(k => k.studentId)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var includedStudents = team.Students
|
||||||
|
.Where(s => !excludedStudentIds.Contains(s.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Create a temporary team with only included students for formatting
|
||||||
|
var teamForFormatting = new Team
|
||||||
|
{
|
||||||
|
Id = team.Id,
|
||||||
|
Event = team.Event,
|
||||||
|
Students = includedStudents,
|
||||||
|
Captain = team.Captain,
|
||||||
|
Identifier = team.Identifier
|
||||||
|
};
|
||||||
|
|
||||||
|
return TeamStudentNameFormatter.FormatStudentList(
|
||||||
|
teamForFormatting,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
Ordering = TeamStudentNameFormatter.OrderingStyle.CaptainFirst,
|
||||||
|
MarkOverlaps = true,
|
||||||
|
HasOverlaps = timeslot.StudentHasOverlaps,
|
||||||
|
MarkAbsent = true,
|
||||||
|
AbsentStudents = absentStudents.ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendUnscheduledStudents(
|
||||||
|
StringBuilder sb,
|
||||||
|
TeamScheduleTimeSlot timeslot,
|
||||||
|
TeamSchedulerSolution solution,
|
||||||
|
IEnumerable<Student> absentStudents)
|
||||||
|
{
|
||||||
|
if (!timeslot.UnscheduledStudents.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
sb.Append("--Unscheduled");
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
|
||||||
|
foreach (var student in timeslot.UnscheduledStudents)
|
||||||
|
{
|
||||||
|
var studentName = StudentNameFormatter.FormatStudentName(
|
||||||
|
student,
|
||||||
|
new StudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
IsAbsent = absentStudents.Contains(student)
|
||||||
|
});
|
||||||
|
|
||||||
|
var unassignedTeams = solution.StudentUnassignedTeams(student);
|
||||||
|
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
||||||
|
|
||||||
|
sb.Append($"{studentName} - {teamsList}");
|
||||||
|
sb.Append(Environment.NewLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for loading meeting schedule data from the database.
|
||||||
|
/// </summary>
|
||||||
|
public class MeetingScheduleDataService : IMeetingScheduleDataService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
public MeetingScheduleDataService(AppDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Team[]> LoadTeamsAsync()
|
||||||
|
{
|
||||||
|
return await _context.Teams
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(e => e.Event)
|
||||||
|
.Include(e => e.Students)
|
||||||
|
.OrderBy(e => e.Event.Name)
|
||||||
|
.ThenBy(e => e.Identifier)
|
||||||
|
.ToArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Student[]> LoadStudentsAsync()
|
||||||
|
{
|
||||||
|
return await _context.Students
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(e => e.Teams)
|
||||||
|
.ThenInclude(t => t.Event)
|
||||||
|
.Include(e => e.Teams)
|
||||||
|
.ThenInclude(t => t.Captain)
|
||||||
|
.Include(e => e.EventRankings)
|
||||||
|
.ThenInclude(e => e.EventDefinition)
|
||||||
|
.OrderBy(e => e.FirstName)
|
||||||
|
.ToArrayAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using WebApp.Models;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
internal record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing meeting schedule state persistence in localStorage.
|
||||||
|
/// </summary>
|
||||||
|
public class MeetingScheduleStateService : IMeetingScheduleStateService
|
||||||
|
{
|
||||||
|
private readonly LocalStorageService _localStorage;
|
||||||
|
private const string ScheduledTeamsKey = "MeetingSchedule_ScheduledTeams";
|
||||||
|
private const string AbsentStudentsKey = "MeetingSchedule_AbsentStudents";
|
||||||
|
private const string TimeSlotCountKey = "MeetingSchedule_TimeSlotCount";
|
||||||
|
private const string ExtendedTeamsKey = "MeetingSchedule_ExtendedTeams";
|
||||||
|
private const string ExcludedStudentsKey = "MeetingSchedule_ExcludedStudents";
|
||||||
|
|
||||||
|
public MeetingScheduleStateService(LocalStorageService localStorage)
|
||||||
|
{
|
||||||
|
_localStorage = localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Team>> LoadScheduledTeamsAsync(Team[] allTeams)
|
||||||
|
{
|
||||||
|
var teamIds = await _localStorage.GetIntArrayAsync(ScheduledTeamsKey);
|
||||||
|
if (teamIds.Length > 0)
|
||||||
|
{
|
||||||
|
return allTeams.Where(t => teamIds.Contains(t.Id)).ToArray();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveScheduledTeamsAsync(IEnumerable<Team> scheduledTeams)
|
||||||
|
{
|
||||||
|
var teamIds = scheduledTeams.Select(t => t.Id).ToArray();
|
||||||
|
await _localStorage.SetIntArrayAsync(ScheduledTeamsKey, teamIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Student>> LoadAbsentStudentsAsync(Student[] allStudents)
|
||||||
|
{
|
||||||
|
var studentIds = await _localStorage.GetIntArrayAsync(AbsentStudentsKey);
|
||||||
|
if (studentIds.Length > 0)
|
||||||
|
{
|
||||||
|
return allStudents.Where(s => studentIds.Contains(s.Id)).ToArray();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAbsentStudentsAsync(IEnumerable<Student> absentStudents)
|
||||||
|
{
|
||||||
|
var studentIds = absentStudents.Select(s => s.Id).ToArray();
|
||||||
|
await _localStorage.SetIntArrayAsync(AbsentStudentsKey, studentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> LoadTimeSlotCountAsync(int defaultValue = 2)
|
||||||
|
{
|
||||||
|
var timeSlots = await _localStorage.GetIntAsync(TimeSlotCountKey, defaultValue);
|
||||||
|
return timeSlots > 0 ? timeSlots : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveTimeSlotCountAsync(int timeSlotCount)
|
||||||
|
{
|
||||||
|
await _localStorage.SetIntAsync(TimeSlotCountKey, timeSlotCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Team>> LoadExtendedTeamsAsync(Team[] allTeams)
|
||||||
|
{
|
||||||
|
var teamIds = await _localStorage.GetIntArrayAsync(ExtendedTeamsKey);
|
||||||
|
if (teamIds.Length > 0)
|
||||||
|
{
|
||||||
|
return allTeams.Where(t => teamIds.Contains(t.Id)).ToArray();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveExtendedTeamsAsync(IEnumerable<Team> extendedTeams)
|
||||||
|
{
|
||||||
|
var teamIds = extendedTeams.Select(t => t.Id).ToArray();
|
||||||
|
await _localStorage.SetIntArrayAsync(ExtendedTeamsKey, teamIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>> LoadExcludedStudentsAsync()
|
||||||
|
{
|
||||||
|
var exclusions = await _localStorage.GetJsonAsync<ExcludedStudent[]>(ExcludedStudentsKey);
|
||||||
|
if (exclusions != null && exclusions.Length > 0)
|
||||||
|
{
|
||||||
|
return exclusions.ToDictionary(
|
||||||
|
e => (e.TeamId, e.TimeSlotIndex, e.StudentId),
|
||||||
|
_ => true);
|
||||||
|
}
|
||||||
|
return new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveExcludedStudentsAsync(Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
var exclusions = excludedStudents.Keys
|
||||||
|
.Where(k => excludedStudents[k])
|
||||||
|
.Select(k => new ExcludedStudent(k.teamId, k.timeSlotIndex, k.studentId))
|
||||||
|
.ToArray();
|
||||||
|
await _localStorage.SetJsonAsync(ExcludedStudentsKey, exclusions);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Services;
|
||||||
using Data;
|
using Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
@@ -10,15 +11,18 @@ public class NotesService : INotesService
|
|||||||
private readonly AppDbContext _context;
|
private readonly AppDbContext _context;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
private readonly ILogger<NotesService> _logger;
|
private readonly ILogger<NotesService> _logger;
|
||||||
|
private readonly INoteNamingService _noteNamingService;
|
||||||
|
|
||||||
public NotesService(
|
public NotesService(
|
||||||
AppDbContext context,
|
AppDbContext context,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ILogger<NotesService> logger)
|
ILogger<NotesService> logger,
|
||||||
|
INoteNamingService noteNamingService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_noteNamingService = noteNamingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? GetCurrentUserEmail()
|
private string? GetCurrentUserEmail()
|
||||||
@@ -30,11 +34,20 @@ public class NotesService : INotesService
|
|||||||
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
|
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Note>> GetNotesAsync()
|
public async Task<IEnumerable<Note>> GetNotesAsync(bool includeDeleted = false)
|
||||||
{
|
{
|
||||||
return await _context.Notes
|
var query = _context.Notes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.OrderByDescending(n => n.UpdatedAt)
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (!includeDeleted)
|
||||||
|
{
|
||||||
|
query = query.Where(n => !n.IsDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
||||||
|
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +58,15 @@ public class NotesService : INotesService
|
|||||||
.FirstOrDefaultAsync(n => n.Id == id);
|
.FirstOrDefaultAsync(n => n.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Note?> GetPageNoteAsync(string pageIdentifier)
|
||||||
|
{
|
||||||
|
var pageNoteTitle = _noteNamingService.GetPageNoteTitle(pageIdentifier);
|
||||||
|
return await _context.Notes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(n => n.Title == pageNoteTitle && !n.IsDeleted)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
|
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
|
||||||
{
|
{
|
||||||
return await _context.NoteHistories
|
return await _context.NoteHistories
|
||||||
@@ -147,16 +169,134 @@ public class NotesService : INotesService
|
|||||||
Content = note.Content,
|
Content = note.Content,
|
||||||
ModifiedBy = userEmail,
|
ModifiedBy = userEmail,
|
||||||
ModifiedAt = now,
|
ModifiedAt = now,
|
||||||
ChangeType = "Deleted"
|
ChangeType = "Soft Deleted"
|
||||||
};
|
};
|
||||||
|
|
||||||
_context.NoteHistories.Add(history);
|
_context.NoteHistories.Add(history);
|
||||||
|
|
||||||
// Delete the note (cascade will handle history, but we want to keep history)
|
// Soft delete - set IsDeleted flag instead of removing
|
||||||
_context.Notes.Remove(note);
|
note.IsDeleted = true;
|
||||||
|
note.UpdatedAt = now;
|
||||||
|
note.LastModifiedBy = userEmail;
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
_logger.LogInformation("Note deleted: {NoteId} by {User}", id, userEmail);
|
_logger.LogInformation("Note soft deleted: {NoteId} by {User}", id, userEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Note>> GetPinnedNotesAsync()
|
||||||
|
{
|
||||||
|
return await _context.Notes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
|
||||||
|
.OrderByDescending(n => n.UpdatedAt)
|
||||||
|
.Take(3)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TogglePinNoteAsync(int noteId)
|
||||||
|
{
|
||||||
|
var note = await _context.Notes
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == noteId);
|
||||||
|
|
||||||
|
if (note == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Note with ID {noteId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent pinning page notes
|
||||||
|
if (_noteNamingService.IsPageNote(note.Title))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Page notes cannot be pinned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userEmail = GetCurrentUserEmail();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// If pinning and already 3 pinned notes exist (excluding this note if it's already pinned)
|
||||||
|
if (!note.IsPinned)
|
||||||
|
{
|
||||||
|
var pinnedCount = await _context.Notes
|
||||||
|
.CountAsync(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted);
|
||||||
|
|
||||||
|
if (pinnedCount >= 3)
|
||||||
|
{
|
||||||
|
// Unpin the oldest pinned note
|
||||||
|
var oldestPinned = await _context.Notes
|
||||||
|
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
|
||||||
|
.OrderBy(n => n.UpdatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (oldestPinned != null)
|
||||||
|
{
|
||||||
|
oldestPinned.IsPinned = false;
|
||||||
|
oldestPinned.UpdatedAt = now;
|
||||||
|
oldestPinned.LastModifiedBy = userEmail;
|
||||||
|
_logger.LogInformation("Auto-unpinned note {NoteId} (oldest) when pinning note {NewNoteId} by {User}",
|
||||||
|
oldestPinned.Id, noteId, userEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the pin status
|
||||||
|
note.IsPinned = !note.IsPinned;
|
||||||
|
note.UpdatedAt = now;
|
||||||
|
note.LastModifiedBy = userEmail;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Note {NoteId} {Action} by {User}", noteId,
|
||||||
|
note.IsPinned ? "pinned" : "unpinned", userEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Note>> GetDeletedNotesAsync()
|
||||||
|
{
|
||||||
|
return await _context.Notes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(n => n.IsDeleted)
|
||||||
|
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
||||||
|
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RestoreNoteAsync(int noteId)
|
||||||
|
{
|
||||||
|
var note = await _context.Notes
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == noteId);
|
||||||
|
|
||||||
|
if (note == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Note with ID {noteId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!note.IsDeleted)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Note with ID {noteId} is not deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userEmail = GetCurrentUserEmail();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Create restore history entry
|
||||||
|
var history = new NoteHistory
|
||||||
|
{
|
||||||
|
NoteId = note.Id,
|
||||||
|
Title = note.Title,
|
||||||
|
Content = note.Content,
|
||||||
|
ModifiedBy = userEmail,
|
||||||
|
ModifiedAt = now,
|
||||||
|
ChangeType = "Restored"
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.NoteHistories.Add(history);
|
||||||
|
|
||||||
|
// Restore the note
|
||||||
|
note.IsDeleted = false;
|
||||||
|
note.UpdatedAt = now;
|
||||||
|
note.LastModifiedBy = userEmail;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Note restored: {NoteId} by {User}", noteId, userEmail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Services;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
public class TeamMeetingHistoryService : ITeamMeetingHistoryService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly ILogger<TeamMeetingHistoryService> _logger;
|
||||||
|
private readonly INotesService _notesService;
|
||||||
|
private readonly INoteNamingService _noteNamingService;
|
||||||
|
|
||||||
|
public TeamMeetingHistoryService(
|
||||||
|
AppDbContext context,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ILogger<TeamMeetingHistoryService> logger,
|
||||||
|
INotesService notesService,
|
||||||
|
INoteNamingService noteNamingService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_logger = logger;
|
||||||
|
_notesService = notesService;
|
||||||
|
_noteNamingService = noteNamingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetCurrentUserEmail()
|
||||||
|
{
|
||||||
|
var user = _httpContextAccessor.HttpContext?.User;
|
||||||
|
if (user == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null)
|
||||||
|
{
|
||||||
|
var query = _context.TeamMeetingHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(tmh => tmh.Teams)
|
||||||
|
.ThenInclude(t => t.Event)
|
||||||
|
.Include(tmh => tmh.Students)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (startDate.HasValue)
|
||||||
|
{
|
||||||
|
var start = startDate.Value.Date;
|
||||||
|
query = query.Where(tmh => tmh.MeetingDate >= start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate.HasValue)
|
||||||
|
{
|
||||||
|
var end = endDate.Value.Date.AddDays(1); // Include the entire end date
|
||||||
|
query = query.Where(tmh => tmh.MeetingDate < end);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(tmh => tmh.MeetingDate)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id)
|
||||||
|
{
|
||||||
|
return await _context.TeamMeetingHistories
|
||||||
|
.Include(tmh => tmh.Teams)
|
||||||
|
.ThenInclude(t => t.Event)
|
||||||
|
.Include(tmh => tmh.Teams)
|
||||||
|
.ThenInclude(t => t.Students)
|
||||||
|
.Include(tmh => tmh.Students)
|
||||||
|
.FirstOrDefaultAsync(tmh => tmh.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
|
||||||
|
{
|
||||||
|
// Create a new meeting history entity to avoid tracking conflicts
|
||||||
|
var newMeetingHistory = new TeamMeetingHistory
|
||||||
|
{
|
||||||
|
MeetingDate = meetingHistory.MeetingDate.Date // Normalize to date only
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach teams by loading them from the database to avoid tracking conflicts
|
||||||
|
var teamIds = meetingHistory.Teams.Select(t => t.Id).ToList();
|
||||||
|
var teams = await _context.Teams
|
||||||
|
.Where(t => teamIds.Contains(t.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Attach students by loading them from the database to avoid tracking conflicts
|
||||||
|
var studentIds = meetingHistory.Students.Select(s => s.Id).ToList();
|
||||||
|
var students = await _context.Students
|
||||||
|
.Where(s => studentIds.Contains(s.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Attach teams and students to the new meeting history
|
||||||
|
newMeetingHistory.Teams = teams;
|
||||||
|
newMeetingHistory.Students = students;
|
||||||
|
|
||||||
|
_context.TeamMeetingHistories.Add(newMeetingHistory);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Meeting history created: {MeetingHistoryId} for date {MeetingDate}",
|
||||||
|
newMeetingHistory.Id, newMeetingHistory.MeetingDate);
|
||||||
|
|
||||||
|
return newMeetingHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
|
||||||
|
{
|
||||||
|
var existingHistory = await _context.TeamMeetingHistories
|
||||||
|
.Include(tmh => tmh.Teams)
|
||||||
|
.Include(tmh => tmh.Students)
|
||||||
|
.FirstOrDefaultAsync(tmh => tmh.Id == meetingHistory.Id);
|
||||||
|
|
||||||
|
if (existingHistory == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Meeting history with ID {meetingHistory.Id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update properties
|
||||||
|
existingHistory.MeetingDate = meetingHistory.MeetingDate.Date; // Normalize to date only
|
||||||
|
|
||||||
|
// Update teams
|
||||||
|
existingHistory.Teams.Clear();
|
||||||
|
foreach (var team in meetingHistory.Teams)
|
||||||
|
{
|
||||||
|
var existingTeam = await _context.Teams.FindAsync(team.Id);
|
||||||
|
if (existingTeam != null)
|
||||||
|
{
|
||||||
|
existingHistory.Teams.Add(existingTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update students
|
||||||
|
existingHistory.Students.Clear();
|
||||||
|
foreach (var student in meetingHistory.Students)
|
||||||
|
{
|
||||||
|
var existingStudent = await _context.Students.FindAsync(student.Id);
|
||||||
|
if (existingStudent != null)
|
||||||
|
{
|
||||||
|
existingHistory.Students.Add(existingStudent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Meeting history updated: {MeetingHistoryId} by {User}",
|
||||||
|
meetingHistory.Id, GetCurrentUserEmail());
|
||||||
|
|
||||||
|
return existingHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteMeetingHistoryAsync(int id)
|
||||||
|
{
|
||||||
|
var meetingHistory = await _context.TeamMeetingHistories
|
||||||
|
.FirstOrDefaultAsync(tmh => tmh.Id == id);
|
||||||
|
|
||||||
|
if (meetingHistory == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Meeting history with ID {id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.TeamMeetingHistories.Remove(meetingHistory);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Meeting history deleted: {MeetingHistoryId}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the meeting note for a specific meeting date by looking it up by title.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meetingDate">The date of the meeting</param>
|
||||||
|
/// <returns>The note if found, null otherwise</returns>
|
||||||
|
public async Task<Note?> GetMeetingNoteAsync(DateTime meetingDate)
|
||||||
|
{
|
||||||
|
var noteTitle = _noteNamingService.GetMeetingNoteTitle(meetingDate);
|
||||||
|
var notes = await _notesService.GetNotesAsync(includeDeleted: false);
|
||||||
|
return notes.FirstOrDefault(n => n.Title == noteTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date)
|
||||||
|
{
|
||||||
|
var normalizedDate = date.Date;
|
||||||
|
return await _context.TeamMeetingHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(tmh => tmh.MeetingDate == normalizedDate)
|
||||||
|
.SelectMany(tmh => tmh.Teams)
|
||||||
|
.Include(t => t.Event)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date)
|
||||||
|
{
|
||||||
|
var normalizedDate = date.Date;
|
||||||
|
return await _context.TeamMeetingHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(tmh => tmh.MeetingDate == normalizedDate)
|
||||||
|
.SelectMany(tmh => tmh.Students)
|
||||||
|
.Distinct()
|
||||||
|
.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
@@ -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,
|
||||||
|
|||||||
+146
-1
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -61,8 +131,31 @@
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.page-header-actions {
|
.page-header-actions {
|
||||||
width: 100%;
|
justify-content: flex-end;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide button text on small screens by setting font-size to 0 */
|
||||||
|
.page-header-actions .mud-button {
|
||||||
|
min-width: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restore icon size - MudBlazor uses material-icons font */
|
||||||
|
.page-header-actions .mud-button .mud-icon-root {
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-actions .mud-button .mud-icon-root .mud-icon,
|
||||||
|
.page-header-actions .mud-button .mud-icon-root .material-icons {
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
width: 1.5rem !important;
|
||||||
|
height: 1.5rem !important;
|
||||||
|
line-height: 1.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header > div > div:first-of-type {
|
.page-header > div > div:first-of-type {
|
||||||
@@ -229,4 +322,56 @@
|
|||||||
.markdown-content img {
|
.markdown-content img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Note color classes - pastel background colors */
|
||||||
|
.note-color-0 {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-color-1 {
|
||||||
|
background-color: #fff9e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-color-2 {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Integration for converting pasted spreadsheet tables to Markdown tables in EasyMDE/CodeMirror
|
||||||
|
// Handles both HTML tables (from Google Sheets) and tab-separated values
|
||||||
|
|
||||||
|
window.markdownTablePaste = {
|
||||||
|
/**
|
||||||
|
* Initializes paste handler for a MarkdownEditor instance.
|
||||||
|
* Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler.
|
||||||
|
* @param {string} editorId - The ID of the editor wrapper element, or null to find by class
|
||||||
|
*/
|
||||||
|
initialize: function(editorId) {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 5;
|
||||||
|
|
||||||
|
const tryInitialize = function() {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
let textarea = null;
|
||||||
|
let codeMirror = null;
|
||||||
|
|
||||||
|
// Try to find the textarea element
|
||||||
|
if (editorId) {
|
||||||
|
const editorElement = document.getElementById(editorId);
|
||||||
|
if (editorElement) {
|
||||||
|
textarea = editorElement.querySelector('textarea');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Find the most recently created EasyMDE textarea (last one in DOM)
|
||||||
|
const containers = document.querySelectorAll('.EasyMDEContainer');
|
||||||
|
if (containers.length > 0) {
|
||||||
|
const lastContainer = containers[containers.length - 1];
|
||||||
|
textarea = lastContainer.querySelector('textarea');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to find any textarea near an editor-toolbar
|
||||||
|
if (!textarea) {
|
||||||
|
const toolbars = document.querySelectorAll('.editor-toolbar');
|
||||||
|
if (toolbars.length > 0) {
|
||||||
|
const lastToolbar = toolbars[toolbars.length - 1];
|
||||||
|
const nextSibling = lastToolbar.nextElementSibling;
|
||||||
|
if (nextSibling && nextSibling.tagName === 'TEXTAREA') {
|
||||||
|
textarea = nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another fallback: find any textarea that's a child of a container with editor classes
|
||||||
|
if (!textarea) {
|
||||||
|
const allTextareas = document.querySelectorAll('textarea');
|
||||||
|
for (let ta of allTextareas) {
|
||||||
|
const parent = ta.parentElement;
|
||||||
|
if (parent && (
|
||||||
|
parent.classList.contains('EasyMDEContainer') ||
|
||||||
|
parent.classList.contains('editor') ||
|
||||||
|
parent.querySelector('.editor-toolbar')
|
||||||
|
)) {
|
||||||
|
textarea = ta;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found a textarea, try to get the CodeMirror instance from EasyMDE
|
||||||
|
if (textarea) {
|
||||||
|
if (window.EasyMDE) {
|
||||||
|
const easyMDEInstances = window.EasyMDE.instances || [];
|
||||||
|
|
||||||
|
for (let i = 0; i < easyMDEInstances.length; i++) {
|
||||||
|
const instance = easyMDEInstances[i];
|
||||||
|
if (instance && instance.codemirror) {
|
||||||
|
const cmTextarea = instance.codemirror.getTextArea();
|
||||||
|
if (cmTextarea === textarea) {
|
||||||
|
codeMirror = instance.codemirror;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: try to get CodeMirror from the textarea's parent
|
||||||
|
if (!codeMirror && textarea.parentElement) {
|
||||||
|
const parent = textarea.parentElement;
|
||||||
|
if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) {
|
||||||
|
codeMirror = parent._easyMDEInstance.codemirror;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = codeMirror || textarea;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
// Add paste handler
|
||||||
|
if (codeMirror) {
|
||||||
|
codeMirror.on('paste', function(cm, event) {
|
||||||
|
handleManualPaste(event, cm);
|
||||||
|
});
|
||||||
|
} else if (textarea) {
|
||||||
|
textarea.addEventListener('paste', function(event) {
|
||||||
|
handleManualPaste(event, textarea);
|
||||||
|
}, true); // Use capture phase to intercept early
|
||||||
|
}
|
||||||
|
return; // Success - we're done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didn't find the textarea, retry
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
setTimeout(tryInitialize, attempts * 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start with initial delay to allow EasyMDE to initialize
|
||||||
|
setTimeout(tryInitialize, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for converting pasted spreadsheet tables to Markdown tables.
|
||||||
|
* Handles both HTML tables (from Google Sheets) and tab-separated values.
|
||||||
|
*/
|
||||||
|
function handleManualPaste(event, target) {
|
||||||
|
const clipboardData = event.clipboardData || window.clipboardData;
|
||||||
|
if (!clipboardData) return;
|
||||||
|
|
||||||
|
const html = clipboardData.getData('text/html');
|
||||||
|
const text = clipboardData.getData('text/plain');
|
||||||
|
|
||||||
|
let markdownTable = null;
|
||||||
|
|
||||||
|
// Check if HTML contains a table
|
||||||
|
if (html && html.includes('<table') && html.includes('</table>')) {
|
||||||
|
markdownTable = convertHtmlTableToMarkdown(html);
|
||||||
|
}
|
||||||
|
// Check if it's tab-separated text (spreadsheet cells)
|
||||||
|
else if (text && text.includes('\t') && text.includes('\n')) {
|
||||||
|
const rows = text.trim().split(/\r?\n/).filter(row => row.trim().length > 0);
|
||||||
|
if (rows.length < 1) return;
|
||||||
|
|
||||||
|
const cells = rows.map(row => row.split('\t').map(cell => cell.trim()));
|
||||||
|
const maxCols = Math.max(...cells.map(row => row.length));
|
||||||
|
|
||||||
|
// Pad rows to have same number of columns
|
||||||
|
const paddedCells = cells.map(row => {
|
||||||
|
while (row.length < maxCols) row.push('');
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape pipe characters in cells
|
||||||
|
const escapeCell = (cell) => cell.replace(/\|/g, '\\|');
|
||||||
|
|
||||||
|
// Build Markdown table
|
||||||
|
const header = paddedCells[0];
|
||||||
|
const body = paddedCells.slice(1);
|
||||||
|
|
||||||
|
const headerRow = '| ' + header.map(escapeCell).join(' | ') + ' |';
|
||||||
|
const separatorRow = '| ' + header.map(() => '---').join(' | ') + ' |';
|
||||||
|
const bodyRows = body.map(row => '| ' + row.map(escapeCell).join(' | ') + ' |');
|
||||||
|
|
||||||
|
markdownTable = [headerRow, separatorRow, ...bodyRows].join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a table to insert, do it
|
||||||
|
if (markdownTable) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Insert into editor
|
||||||
|
if (target.getDoc && target.getDoc().replaceRange) {
|
||||||
|
// CodeMirror
|
||||||
|
const doc = target.getDoc();
|
||||||
|
const cursor = doc.getCursor();
|
||||||
|
doc.replaceRange(markdownTable, cursor);
|
||||||
|
} else if (target.setRangeText) {
|
||||||
|
// Textarea with setRangeText
|
||||||
|
const start = target.selectionStart;
|
||||||
|
const end = target.selectionEnd;
|
||||||
|
target.setRangeText(markdownTable, start, end, 'end');
|
||||||
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
// Fallback: insert at cursor
|
||||||
|
const start = target.selectionStart || 0;
|
||||||
|
const end = target.selectionEnd || 0;
|
||||||
|
const before = target.value.substring(0, start);
|
||||||
|
const after = target.value.substring(end);
|
||||||
|
target.value = before + markdownTable + after;
|
||||||
|
target.selectionStart = target.selectionEnd = before.length + markdownTable.length;
|
||||||
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an HTML table to Markdown table format
|
||||||
|
*/
|
||||||
|
function convertHtmlTableToMarkdown(html) {
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
|
const table = tempDiv.querySelector('table');
|
||||||
|
if (!table) return null;
|
||||||
|
|
||||||
|
const rows = table.querySelectorAll('tr');
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const escapeCell = (cell) => {
|
||||||
|
const text = cell.textContent || cell.innerText || '';
|
||||||
|
return text.trim().replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdownRows = [];
|
||||||
|
|
||||||
|
// Process header row (first row)
|
||||||
|
const headerRow = rows[0];
|
||||||
|
const headerCells = headerRow.querySelectorAll('th, td');
|
||||||
|
const headerText = Array.from(headerCells).map(escapeCell);
|
||||||
|
markdownRows.push('| ' + headerText.join(' | ') + ' |');
|
||||||
|
|
||||||
|
// Add separator
|
||||||
|
markdownRows.push('| ' + headerText.map(() => '---').join(' | ') + ' |');
|
||||||
|
|
||||||
|
// Process data rows
|
||||||
|
for (let i = 1; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const cells = row.querySelectorAll('td, th');
|
||||||
|
const cellText = Array.from(cells).map(escapeCell);
|
||||||
|
markdownRows.push('| ' + cellText.join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdownRows.join('\n') + '\n';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user