Compare commits
55
Commits
45edcf5e5f
..
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 | ||
|
|
5c4aaf91df | ||
|
|
5fdda08627 | ||
|
|
68311f4012 | ||
|
|
27f08b3718 | ||
|
|
34658e9697 | ||
|
|
8c4c21f204 | ||
|
|
52bf303537 | ||
|
|
c505bf42dd | ||
|
|
aba8ea3ae3 | ||
|
|
e2a5767b04 | ||
|
|
1601610226 | ||
|
|
f8c22690d4 | ||
|
|
6cd4418142 | ||
|
|
6acbc4e852 | ||
|
|
5a1b3fad2e | ||
|
|
8af86e22d9 | ||
|
|
e53403c934 | ||
|
|
5e6d61d400 | ||
|
|
37e82646b8 | ||
|
|
e77f34ff9f |
+3
-1
@@ -23,7 +23,8 @@ _ReSharper*/
|
||||
|
||||
DataBackup/
|
||||
*.db
|
||||
/.claude/*
|
||||
/.*/*
|
||||
.*rules
|
||||
|
||||
# Production secrets and configuration
|
||||
auth-secrets.json
|
||||
@@ -34,3 +35,4 @@ docker-compose.override.yml
|
||||
|
||||
# Runtime data directory
|
||||
/WebApp/Data/*
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ namespace Core.Calculation
|
||||
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
||||
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
||||
{
|
||||
var buffer = new List<ILiteral>();
|
||||
List<ILiteral> buffer = [];
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
buffer.Clear();
|
||||
@@ -273,7 +273,7 @@ namespace Core.Calculation
|
||||
/// </summary>
|
||||
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
||||
{
|
||||
var prohibitVar = new List<IntVar>(1);
|
||||
List<IntVar> prohibitVar = [];
|
||||
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
@@ -328,7 +328,7 @@ namespace Core.Calculation
|
||||
/// </summary>
|
||||
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
||||
{
|
||||
var studentCapacity = new List<IntVar>();
|
||||
List<IntVar> studentCapacity = [];
|
||||
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
@@ -418,7 +418,7 @@ namespace Core.Calculation
|
||||
/// </summary>
|
||||
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
||||
{
|
||||
var eventCapacity = new List<IntVar>();
|
||||
List<IntVar> eventCapacity = [];
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
eventCapacity.Add(x[eventIndex, s]);
|
||||
@@ -455,7 +455,7 @@ namespace Core.Calculation
|
||||
model.AddAssumption(x[e, s]);
|
||||
}
|
||||
|
||||
var prohibitVar = new List<IntVar>(1);
|
||||
List<IntVar> prohibitVar = [];
|
||||
foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude))
|
||||
{
|
||||
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -82,10 +82,11 @@ public class TeamScheduler
|
||||
var model = new CpModel();
|
||||
|
||||
// Build membership matrix: m[i,t] = 1 if student i is on team t, else 0
|
||||
// Use team.Students to support PartialTeam objects with omitted students
|
||||
var m = new int[_students.Length,_teams.Length];
|
||||
foreach (var i in _students)
|
||||
foreach (var t in _teams)
|
||||
m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0;
|
||||
m[i, t] = _teamObjects[t].Students.Any(s => s.Id == _studentObjects[i].Id) ? 1 : 0;
|
||||
|
||||
// Decision variables:
|
||||
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ public class TeamSchedulerSolution(
|
||||
public Team[] StudentUnassignedTeams(Student student)
|
||||
{
|
||||
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
|
||||
return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
||||
var meetingTeamIds = meetingTeams.Select(t => t.Id).ToHashSet();
|
||||
return student.Teams.Where(e => !meetingTeamIds.Contains(e.Id)).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class TeamScheduler_DecisionTree
|
||||
{
|
||||
var timeSlots = new IList<Team>[_timeSlotCount];
|
||||
for (var i = 0; i < _timeSlotCount; i++)
|
||||
timeSlots[i] = new List<Team>();
|
||||
timeSlots[i] = [];
|
||||
|
||||
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
||||
{
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||
<PackageReference Include="Google.OrTools" Version="9.7.2996" />
|
||||
<PackageReference Include="Google.OrTools" Version="9.14.6206" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||
|
||||
@@ -43,13 +43,10 @@ public class CareerField
|
||||
Id = id;
|
||||
Name = name;
|
||||
Description = description;
|
||||
DirectCareerMatches = directCareerMatches ?? Array.Empty<string>();
|
||||
PatternKeywords = patternKeywords ?? Array.Empty<string>();
|
||||
DirectCareerMatches = directCareerMatches ?? [];
|
||||
PatternKeywords = patternKeywords ?? [];
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,21 +37,6 @@ public class EventDefinition
|
||||
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
||||
|
||||
public bool OnSiteActivity { get; set; }
|
||||
//=> SemifinalistActivity != null
|
||||
// && (SemifinalistActivity.Contains("Challenge")
|
||||
// || SemifinalistActivity.Contains("Race")
|
||||
// || SemifinalistActivity.Contains("Speech")
|
||||
// || SemifinalistActivity.Contains("Test")
|
||||
// || SemifinalistActivity.Contains("Flight")
|
||||
// || Name.Contains("Leadership")
|
||||
// || Name.Contains("Forensic")
|
||||
// || Name.Contains("Flight")
|
||||
// || Name.Contains("Coding")
|
||||
// || SemifinalistActivity.Contains("Debate")
|
||||
// || SemifinalistActivity.Contains("Photography")
|
||||
// || SemifinalistActivity.Contains("Build")
|
||||
// || Name.Contains("Chapter")
|
||||
// || Name.Contains("Podcast"));
|
||||
|
||||
[StringLength(1024)]
|
||||
public string? Notes { get; set; }
|
||||
@@ -79,15 +64,12 @@ public class EventDefinition
|
||||
public string? Description { get; set; }
|
||||
public int? LevelOfEffort { get; set; }
|
||||
|
||||
public ICollection<Career> RelatedCareers { get; set; } = new List<Career>();
|
||||
public ICollection<Career> RelatedCareers { get; set; } = [];
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public string? RelatedCareersText { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
public override string ToString() => Name;
|
||||
|
||||
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
||||
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Core.Entities;
|
||||
|
||||
public class Note
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
[Display(Name = "Title")]
|
||||
public string Title { get; set; } = null!;
|
||||
|
||||
[Display(Name = "Content")]
|
||||
public string? Content { get; set; }
|
||||
|
||||
[Display(Name = "Created At")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Display(Name = "Updated At")]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
[Display(Name = "Created By")]
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
[Display(Name = "Last Modified By")]
|
||||
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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Core.Entities;
|
||||
|
||||
public class NoteHistory
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[Display(Name = "Note Id")]
|
||||
public int NoteId { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
[Display(Name = "Title")]
|
||||
public string Title { get; set; } = null!;
|
||||
|
||||
[Display(Name = "Content")]
|
||||
public string? Content { get; set; }
|
||||
|
||||
[Display(Name = "Modified By")]
|
||||
public string? ModifiedBy { get; set; }
|
||||
|
||||
[Display(Name = "Modified At")]
|
||||
public DateTime ModifiedAt { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
[Display(Name = "Change Type")]
|
||||
public string ChangeType { get; set; } = null!;
|
||||
|
||||
public Note Note { get; set; } = null!;
|
||||
}
|
||||
+2
-15
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Core.Models;
|
||||
|
||||
namespace Core.Entities;
|
||||
@@ -61,19 +61,6 @@ public class Team
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
||||
return $"{Event?.Name ?? "(no event)"} {(Identifier != null ? $"({Identifier})" : "")}";
|
||||
}
|
||||
|
||||
public string StudentsFirstNames
|
||||
{
|
||||
get
|
||||
{
|
||||
return
|
||||
string.Join(", ",
|
||||
Students.Select(e =>
|
||||
e.FirstName
|
||||
+ (Captain != null && (Captain.Equals(e)) ? "(Cpt)" : ""))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ public static class EventOccurrenceGrammar
|
||||
/// Array of all month names in order (January through December).
|
||||
/// This is the single source of truth for month names used throughout the parser.
|
||||
/// </summary>
|
||||
public static readonly string[] MonthNames = new[]
|
||||
{
|
||||
public static readonly string[] MonthNames =
|
||||
[
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
};
|
||||
];
|
||||
|
||||
// Build month parsers dynamically from MonthNames array
|
||||
private static readonly Parser<string>[] MonthParsers = MonthNames
|
||||
|
||||
@@ -27,7 +27,7 @@ public class StudentEventRankingParser : CsvParserBase
|
||||
continue;
|
||||
|
||||
|
||||
var competitiveEvents = new List<EventDefinition>(6);
|
||||
var competitiveEvents = new List<EventDefinition>();
|
||||
|
||||
for (var i = 1; i <= 6; i++)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ public class StudentParser : CsvParserBase
|
||||
|
||||
public Student[] Parse()
|
||||
{
|
||||
var s = new List<Student>();
|
||||
var students = new List<Student>();
|
||||
|
||||
CsvReader.Read();
|
||||
CsvReader.ReadHeader();
|
||||
@@ -44,9 +44,9 @@ public class StudentParser : CsvParserBase
|
||||
RegionalId = regionalId,
|
||||
NationalId = nationalId
|
||||
};
|
||||
s.Add(student);
|
||||
students.Add(student);
|
||||
}
|
||||
|
||||
return s.ToArray();
|
||||
return students.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);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public static class CareerFieldDefinitions
|
||||
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
|
||||
{
|
||||
if (careers == null)
|
||||
return Array.Empty<CareerField>();
|
||||
return [];
|
||||
|
||||
var careerNames = careers
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
@@ -31,7 +31,7 @@ public static class CareerFieldDefinitions
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!careerNames.Any())
|
||||
return Array.Empty<CareerField>();
|
||||
return [];
|
||||
|
||||
var matchingFields = new HashSet<CareerField>();
|
||||
var allFields = GetAllCareerFields();
|
||||
@@ -74,15 +74,15 @@ public static class CareerFieldDefinitions
|
||||
|
||||
private static IReadOnlyList<CareerField> CreateAllCareerFields()
|
||||
{
|
||||
return new List<CareerField>
|
||||
{
|
||||
return
|
||||
[
|
||||
// 1. Aerospace & Automotive Engineering
|
||||
new CareerField(
|
||||
1,
|
||||
"Aerospace & Automotive Engineering",
|
||||
"Careers focused on designing and engineering aircraft, spacecraft, and vehicles for transportation.",
|
||||
new[] { "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" },
|
||||
new[] { "aeronautical", "aircraft", "automobile", "automotive", "race car" }
|
||||
[ "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" ],
|
||||
[ "aeronautical", "aircraft", "automobile", "automotive", "race car" ]
|
||||
),
|
||||
|
||||
// 2. Mechanical & Robotics Engineering
|
||||
@@ -90,8 +90,8 @@ public static class CareerFieldDefinitions
|
||||
2,
|
||||
"Mechanical & Robotics Engineering",
|
||||
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
|
||||
new[] { "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" },
|
||||
new[] { "mechanical", "robotics", "machine" }
|
||||
[ "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" ],
|
||||
[ "mechanical", "robotics", "machine" ]
|
||||
),
|
||||
|
||||
// 3. Electrical & Electronics Engineering
|
||||
@@ -99,8 +99,8 @@ public static class CareerFieldDefinitions
|
||||
3,
|
||||
"Electrical & Electronics Engineering",
|
||||
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
|
||||
new[] { "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" },
|
||||
new[] { "electrical", "electronic", "electrician" }
|
||||
[ "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" ],
|
||||
[ "electrical", "electronic", "electrician" ]
|
||||
),
|
||||
|
||||
// 4. Civil & Structural Engineering
|
||||
@@ -108,8 +108,8 @@ public static class CareerFieldDefinitions
|
||||
4,
|
||||
"Civil & Structural Engineering",
|
||||
"Engineering fields focused on infrastructure, buildings, bridges, and construction project management.",
|
||||
new[] { "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" },
|
||||
new[] { "civil", "construction", "structural", "contractor" }
|
||||
[ "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" ],
|
||||
[ "civil", "construction", "structural", "contractor" ]
|
||||
),
|
||||
|
||||
// 5. Environmental & Energy Engineering
|
||||
@@ -117,8 +117,8 @@ public static class CareerFieldDefinitions
|
||||
5,
|
||||
"Environmental & Energy Engineering",
|
||||
"Engineering careers focused on sustainable energy solutions, environmental protection, and chemical processes.",
|
||||
new[] { "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" },
|
||||
new[] { "chemical", "energy", "environmental", "solar" }
|
||||
[ "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" ],
|
||||
[ "chemical", "energy", "environmental", "solar" ]
|
||||
),
|
||||
|
||||
// 6. General Engineering & Quality
|
||||
@@ -126,8 +126,8 @@ public static class CareerFieldDefinitions
|
||||
6,
|
||||
"General Engineering & Quality",
|
||||
"Broad engineering roles including management, quality assurance, and standards compliance across various industries.",
|
||||
new[] { "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" },
|
||||
new[] { "engineer", "quality", "standards" }
|
||||
[ "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" ],
|
||||
[ "engineer", "quality", "standards" ]
|
||||
),
|
||||
|
||||
// 7. Architecture & Urban Planning
|
||||
@@ -135,8 +135,8 @@ public static class CareerFieldDefinitions
|
||||
7,
|
||||
"Architecture & Urban Planning",
|
||||
"Design and planning careers focused on buildings, spaces, and community development.",
|
||||
new[] { "Architect", "Community planner", "Interior designer", "Urban and regional planner" },
|
||||
new[] { "architect", "planner", "interior design", "urban" }
|
||||
[ "Architect", "Community planner", "Interior designer", "Urban and regional planner" ],
|
||||
[ "architect", "planner", "interior design", "urban" ]
|
||||
),
|
||||
|
||||
// 8. Software Development
|
||||
@@ -144,8 +144,8 @@ public static class CareerFieldDefinitions
|
||||
8,
|
||||
"Software Development",
|
||||
"Careers in creating, designing, and developing computer software applications and systems.",
|
||||
new[] { "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" },
|
||||
new[] { "programming", "programmer", "software", "developer" }
|
||||
[ "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" ],
|
||||
[ "programming", "programmer", "software", "developer" ]
|
||||
),
|
||||
|
||||
// 9. IT & Networking
|
||||
@@ -153,8 +153,8 @@ public static class CareerFieldDefinitions
|
||||
9,
|
||||
"IT & Networking",
|
||||
"Information technology careers involving computer systems, networks, technical support, and telecommunications.",
|
||||
new[] { "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" },
|
||||
new[] { "network", "computer", "technical support", "telecommunications", "IT" }
|
||||
[ "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" ],
|
||||
[ "network", "computer", "technical support", "telecommunications", "IT" ]
|
||||
),
|
||||
|
||||
// 10. Cybersecurity & Digital Forensics
|
||||
@@ -162,8 +162,8 @@ public static class CareerFieldDefinitions
|
||||
10,
|
||||
"Cybersecurity & Digital Forensics",
|
||||
"Security-focused careers protecting digital systems, investigating cybercrimes, and ensuring information security.",
|
||||
new[] { "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" },
|
||||
new[] { "cyber", "security", "forensics", "cryptography", "vulnerability" }
|
||||
[ "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" ],
|
||||
[ "cyber", "security", "forensics", "cryptography", "vulnerability" ]
|
||||
),
|
||||
|
||||
// 11. Data Science & Analytics
|
||||
@@ -171,8 +171,8 @@ public static class CareerFieldDefinitions
|
||||
11,
|
||||
"Data Science & Analytics",
|
||||
"Careers analyzing data, applying mathematical and statistical methods to solve problems and make decisions.",
|
||||
new[] { "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" },
|
||||
new[] { "data", "analyst", "actuary", "economist", "mathematician", "research" }
|
||||
[ "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" ],
|
||||
[ "data", "analyst", "actuary", "economist", "mathematician", "research" ]
|
||||
),
|
||||
|
||||
// 12. CAD, CNC & Manufacturing
|
||||
@@ -180,8 +180,8 @@ public static class CareerFieldDefinitions
|
||||
12,
|
||||
"CAD, CNC & Manufacturing",
|
||||
"Careers in computer-aided design, manufacturing processes, and production planning.",
|
||||
new[] { "CAD professional", "CNC programmer", "Manufacturing", "Production planner" },
|
||||
new[] { "CAD", "CNC", "manufacturing", "production" }
|
||||
[ "CAD professional", "CNC programmer", "Manufacturing", "Production planner" ],
|
||||
[ "CAD", "CNC", "manufacturing", "production" ]
|
||||
),
|
||||
|
||||
// 13. Industrial & Product Design
|
||||
@@ -189,8 +189,8 @@ public static class CareerFieldDefinitions
|
||||
13,
|
||||
"Industrial & Product Design",
|
||||
"Design careers creating products, commercial goods, and industrial solutions with focus on form and function.",
|
||||
new[] { "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" },
|
||||
new[] { "designer", "design", "industrial", "product", "appraiser" }
|
||||
[ "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" ],
|
||||
[ "designer", "design", "industrial", "product", "appraiser" ]
|
||||
),
|
||||
|
||||
// 14. Visual Arts & Animation
|
||||
@@ -198,8 +198,8 @@ public static class CareerFieldDefinitions
|
||||
14,
|
||||
"Visual Arts & Animation",
|
||||
"Creative careers in visual design, illustration, animation, and digital art creation.",
|
||||
new[] { "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" },
|
||||
new[] { "animator", "artist", "graphic", "illustrator", "multimedia" }
|
||||
[ "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" ],
|
||||
[ "animator", "artist", "graphic", "illustrator", "multimedia" ]
|
||||
),
|
||||
|
||||
// 15. Game Design & Interactive Media
|
||||
@@ -207,8 +207,8 @@ public static class CareerFieldDefinitions
|
||||
15,
|
||||
"Game Design & Interactive Media",
|
||||
"Careers in video game design, development, testing, and professional gaming.",
|
||||
new[] { "Game designer", "Game Play Tester", "Professional Gamer" },
|
||||
new[] { "game", "gamer", "gaming" }
|
||||
[ "Game designer", "Game Play Tester", "Professional Gamer" ],
|
||||
[ "game", "gamer", "gaming" ]
|
||||
),
|
||||
|
||||
// 16. Audio & Music Production
|
||||
@@ -216,8 +216,8 @@ public static class CareerFieldDefinitions
|
||||
16,
|
||||
"Audio & Music Production",
|
||||
"Careers in audio engineering, music composition, sound design, and broadcast technology.",
|
||||
new[] { "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" },
|
||||
new[] { "audio", "music", "broadcast", "sound" }
|
||||
[ "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" ],
|
||||
[ "audio", "music", "broadcast", "sound" ]
|
||||
),
|
||||
|
||||
// 17. Video & Film Production
|
||||
@@ -225,8 +225,8 @@ public static class CareerFieldDefinitions
|
||||
17,
|
||||
"Video & Film Production",
|
||||
"Careers in video production, filmmaking, directing, and television broadcasting.",
|
||||
new[] { "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" },
|
||||
new[] { "video", "film", "director", "television", "broadcast", "videographer" }
|
||||
[ "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" ],
|
||||
[ "video", "film", "director", "television", "broadcast", "videographer" ]
|
||||
),
|
||||
|
||||
// 18. Web & Digital Communications
|
||||
@@ -234,8 +234,8 @@ public static class CareerFieldDefinitions
|
||||
18,
|
||||
"Web & Digital Communications",
|
||||
"Careers in web design, digital communication, and instructional technology.",
|
||||
new[] { "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" },
|
||||
new[] { "web", "website", "digital", "communications", "webmaster" }
|
||||
[ "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" ],
|
||||
[ "web", "website", "digital", "communications", "webmaster" ]
|
||||
),
|
||||
|
||||
// 19. Writing & Publishing
|
||||
@@ -243,8 +243,8 @@ public static class CareerFieldDefinitions
|
||||
19,
|
||||
"Writing & Publishing",
|
||||
"Careers in writing, editing, publishing, and content creation across various media formats.",
|
||||
new[] { "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" },
|
||||
new[] { "writing", "writer", "editor", "publisher", "copy" }
|
||||
[ "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" ],
|
||||
[ "writing", "writer", "editor", "publisher", "copy" ]
|
||||
),
|
||||
|
||||
// 20. Journalism & Public Relations
|
||||
@@ -252,8 +252,8 @@ public static class CareerFieldDefinitions
|
||||
20,
|
||||
"Journalism & Public Relations",
|
||||
"Careers in news reporting, photojournalism, public relations, and communications management.",
|
||||
new[] { "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" },
|
||||
new[] { "journalism", "reporter", "photojournalist", "communications", "speaker" }
|
||||
[ "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" ],
|
||||
[ "journalism", "reporter", "photojournalist", "communications", "speaker" ]
|
||||
),
|
||||
|
||||
// 21. Forensics & Criminal Investigation
|
||||
@@ -261,8 +261,8 @@ public static class CareerFieldDefinitions
|
||||
21,
|
||||
"Forensics & Criminal Investigation",
|
||||
"Careers in criminal investigation, forensic science, and analyzing evidence for legal proceedings.",
|
||||
new[] { "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" },
|
||||
new[] { "forensic", "detective", "investigator", "crime" }
|
||||
[ "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" ],
|
||||
[ "forensic", "detective", "investigator", "crime" ]
|
||||
),
|
||||
|
||||
// 22. Healthcare & Medical Technology
|
||||
@@ -270,8 +270,8 @@ public static class CareerFieldDefinitions
|
||||
22,
|
||||
"Healthcare & Medical Technology",
|
||||
"Medical and healthcare careers providing patient care, medical technology, and health services.",
|
||||
new[] { "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" },
|
||||
new[] { "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" }
|
||||
[ "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" ],
|
||||
[ "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" ]
|
||||
),
|
||||
|
||||
// 23. Science & Research
|
||||
@@ -279,8 +279,8 @@ public static class CareerFieldDefinitions
|
||||
23,
|
||||
"Science & Research",
|
||||
"Scientific research careers across biology, physics, meteorology, and other scientific disciplines.",
|
||||
new[] { "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" },
|
||||
new[] { "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" }
|
||||
[ "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" ],
|
||||
[ "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" ]
|
||||
),
|
||||
|
||||
// 24. Education & Training
|
||||
@@ -288,8 +288,8 @@ public static class CareerFieldDefinitions
|
||||
24,
|
||||
"Education & Training",
|
||||
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
|
||||
new[] { "Educator", "Teacher/trainer", "Technology education instructor" },
|
||||
new[] { "educator", "teacher", "trainer", "education", "instructor" }
|
||||
[ "Educator", "Teacher/trainer", "Technology education instructor" ],
|
||||
[ "educator", "teacher", "trainer", "education", "instructor" ]
|
||||
),
|
||||
|
||||
// 25. Business, Legal & Government
|
||||
@@ -297,10 +297,10 @@ public static class CareerFieldDefinitions
|
||||
25,
|
||||
"Business, Legal & Government",
|
||||
"Careers in business management, legal services, government, politics, and public policy.",
|
||||
new[] { "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" },
|
||||
new[] { "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" }
|
||||
[ "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" ],
|
||||
[ "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" ]
|
||||
)
|
||||
};
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Utility;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for formatting individual student names with overlap and absent markers.
|
||||
/// </summary>
|
||||
public static class StudentNameFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for formatting student names.
|
||||
/// </summary>
|
||||
public record FormatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the student is absent. If true, adds "(absent)" suffix. Default is false.
|
||||
/// </summary>
|
||||
public bool IsAbsent { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the student has schedule overlaps. If true, adds "*" suffix. Default is false.
|
||||
/// </summary>
|
||||
public bool HasOverlap { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single student name with overlap and absent markers.
|
||||
/// </summary>
|
||||
/// <param name="student">The student to format.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>Formatted student name.</returns>
|
||||
public static string FormatStudentName(Student student, FormatOptions options)
|
||||
{
|
||||
if (student == null)
|
||||
return string.Empty;
|
||||
|
||||
var name = student.FirstName;
|
||||
|
||||
// Add overlap marker
|
||||
if (options.HasOverlap)
|
||||
{
|
||||
name += "*";
|
||||
}
|
||||
|
||||
// Add absent marker
|
||||
if (options.IsAbsent)
|
||||
{
|
||||
name += " (absent)";
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Utility;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for formatting student names for teams with various formatting options.
|
||||
/// </summary>
|
||||
public static class TeamStudentNameFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for formatting student names.
|
||||
/// </summary>
|
||||
public record FormatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Style for indicating the team captain. Default is None.
|
||||
/// </summary>
|
||||
public CaptainIndicatorStyle CaptainIndicator { get; init; } = CaptainIndicatorStyle.None;
|
||||
|
||||
/// <summary>
|
||||
/// How to order the students. Default is None (preserve original order).
|
||||
/// </summary>
|
||||
public OrderingStyle Ordering { get; init; } = OrderingStyle.None;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to add "*" suffix for students with schedule overlaps. Default is false.
|
||||
/// </summary>
|
||||
public bool MarkOverlaps { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to add "(absent)" suffix for absent students. Default is false.
|
||||
/// </summary>
|
||||
public bool MarkAbsent { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Function to determine if a student has overlaps. Required if MarkOverlaps is true.
|
||||
/// </summary>
|
||||
public Func<Student, bool>? HasOverlaps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Collection of absent students. Required if MarkAbsent is true.
|
||||
/// </summary>
|
||||
public ICollection<Student>? AbsentStudents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only show captain indicator for team events (EventFormat.Team). Default is true.
|
||||
/// </summary>
|
||||
public bool OnlyTeamEventsForCaptain { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Style for indicating the team captain.
|
||||
/// </summary>
|
||||
public enum CaptainIndicatorStyle
|
||||
{
|
||||
/// <summary>
|
||||
/// No captain indicator.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Use asterisk (*) for captain.
|
||||
/// </summary>
|
||||
Star,
|
||||
|
||||
/// <summary>
|
||||
/// Use "(Cpt)" for captain.
|
||||
/// </summary>
|
||||
Captain
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Style for ordering students.
|
||||
/// </summary>
|
||||
public enum OrderingStyle
|
||||
{
|
||||
/// <summary>
|
||||
/// Preserve original order.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Captain first, then alphabetical by first name.
|
||||
/// </summary>
|
||||
CaptainFirst,
|
||||
|
||||
/// <summary>
|
||||
/// Alphabetical by first name.
|
||||
/// </summary>
|
||||
Alphabetical,
|
||||
|
||||
/// <summary>
|
||||
/// By grade + TSA year descending (highest first).
|
||||
/// </summary>
|
||||
GradeDescending
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single student name with the specified options.
|
||||
/// </summary>
|
||||
/// <param name="student">The student to format.</param>
|
||||
/// <param name="team">The team the student belongs to.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>Formatted student name.</returns>
|
||||
public static string FormatStudentName(Student student, Team team, FormatOptions options)
|
||||
{
|
||||
if (student == null)
|
||||
return string.Empty;
|
||||
|
||||
var name = student.FirstName;
|
||||
|
||||
// Add captain indicator (before overlap/absent markers)
|
||||
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student))
|
||||
{
|
||||
var shouldShow = !options.OnlyTeamEventsForCaptain || (team.Event != null && team.Event.EventFormat == EventFormat.Team);
|
||||
if (shouldShow)
|
||||
{
|
||||
name += options.CaptainIndicator switch
|
||||
{
|
||||
CaptainIndicatorStyle.Star => "*",
|
||||
CaptainIndicatorStyle.Captain => "(Cpt)",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Convert collections/functions to booleans for StudentNameFormatter
|
||||
var hasOverlap = options.MarkOverlaps && options.HasOverlaps != null && options.HasOverlaps(student);
|
||||
var isAbsent = options.MarkAbsent && options.AbsentStudents != null && options.AbsentStudents.Contains(student);
|
||||
|
||||
// Use StudentNameFormatter for overlap/absent markers (appended after captain indicator)
|
||||
var studentNameOptions = new StudentNameFormatter.FormatOptions
|
||||
{
|
||||
HasOverlap = hasOverlap,
|
||||
IsAbsent = isAbsent
|
||||
};
|
||||
|
||||
// Get the suffix from StudentNameFormatter (overlap/absent markers)
|
||||
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
|
||||
var suffix = baseFormatted.Substring(student.FirstName.Length);
|
||||
|
||||
return name + suffix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats all students for a team as a comma-separated string.
|
||||
/// </summary>
|
||||
/// <param name="team">The team to format students for.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>Comma-separated string of formatted student names.</returns>
|
||||
public static string FormatStudentList(Team team, FormatOptions options)
|
||||
{
|
||||
if (team?.Students == null || !team.Students.Any())
|
||||
return string.Empty;
|
||||
|
||||
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||
|
||||
return string.Join(", ", students.Select(s => FormatStudentName(s, team, options)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats all students for a team as a list of strings.
|
||||
/// </summary>
|
||||
/// <param name="team">The team to format students for.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>List of formatted student names.</returns>
|
||||
public static List<string> FormatStudentListAsList(Team team, FormatOptions options)
|
||||
{
|
||||
if (team?.Students == null || !team.Students.Any())
|
||||
return [];
|
||||
|
||||
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||
|
||||
return students.Select(s => FormatStudentName(s, team, options)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats all unique students across multiple teams for an event definition.
|
||||
/// Returns a list of unique student names (by student ID).
|
||||
/// </summary>
|
||||
/// <param name="eventDefinition">The event definition.</param>
|
||||
/// <param name="teams">The teams for this event.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>List of unique formatted student names.</returns>
|
||||
public static List<string> FormatStudentListForEvent(EventDefinition eventDefinition, IEnumerable<Team> teams, FormatOptions options)
|
||||
{
|
||||
if (eventDefinition == null || teams == null)
|
||||
return [];
|
||||
|
||||
var teamsList = teams.ToList();
|
||||
if (!teamsList.Any())
|
||||
return [];
|
||||
|
||||
// Get all unique students from all teams for this event
|
||||
var allStudents = teamsList
|
||||
.SelectMany(t => t.Students)
|
||||
.DistinctBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
if (!allStudents.Any())
|
||||
return [];
|
||||
|
||||
// Determine if each student is a captain in any team
|
||||
var studentsWithCaptainInfo = allStudents.Select(s =>
|
||||
{
|
||||
var isCaptain = teamsList.Any(t => t.Captain != null && t.Captain.Equals(s));
|
||||
// Find a team this student belongs to for formatting context
|
||||
var studentTeam = teamsList.FirstOrDefault(t => t.Students.Contains(s));
|
||||
return new { Student = s, IsCaptain = isCaptain, Team = studentTeam };
|
||||
}).ToList();
|
||||
|
||||
// Apply ordering
|
||||
var orderedStudents = options.Ordering switch
|
||||
{
|
||||
OrderingStyle.CaptainFirst => studentsWithCaptainInfo
|
||||
.OrderBy(x => !x.IsCaptain)
|
||||
.ThenBy(x => x.Student.FirstName)
|
||||
.Select(x => x.Student),
|
||||
OrderingStyle.Alphabetical => studentsWithCaptainInfo
|
||||
.OrderBy(x => x.Student.FirstName)
|
||||
.Select(x => x.Student),
|
||||
OrderingStyle.GradeDescending => studentsWithCaptainInfo
|
||||
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
|
||||
.Select(x => x.Student),
|
||||
_ => studentsWithCaptainInfo.Select(x => x.Student)
|
||||
};
|
||||
|
||||
// Format names - for event-level formatting, we need to handle captain indicator specially
|
||||
// since a student might be captain in one team but not another
|
||||
return orderedStudents.Select(s =>
|
||||
{
|
||||
var studentInfo = studentsWithCaptainInfo.First(x => x.Student.Equals(s));
|
||||
// Use the student's team if available, otherwise create a minimal team for formatting context
|
||||
var team = studentInfo.Team ?? new Team { Students = [s], Event = eventDefinition };
|
||||
|
||||
// Create options that handle captain indicator for event-level
|
||||
var eventOptions = options with
|
||||
{
|
||||
CaptainIndicator = studentInfo.IsCaptain &&
|
||||
(!options.OnlyTeamEventsForCaptain || eventDefinition.EventFormat == EventFormat.Team)
|
||||
? options.CaptainIndicator
|
||||
: CaptainIndicatorStyle.None
|
||||
};
|
||||
|
||||
return FormatStudentName(s, team, eventOptions);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<Student> ApplyOrdering(IEnumerable<Student> students, Team team, OrderingStyle ordering)
|
||||
{
|
||||
return ordering switch
|
||||
{
|
||||
OrderingStyle.CaptainFirst => students
|
||||
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
|
||||
.ThenBy(s => s.FirstName),
|
||||
OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName),
|
||||
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
|
||||
_ => students
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Reflection;
|
||||
using Core.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -12,6 +12,9 @@ namespace Data
|
||||
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
||||
public DbSet<EventOccurrence> EventOccurrences { get; set; }
|
||||
public DbSet<Career> Careers { get; set; }
|
||||
public DbSet<Note> Notes { get; set; }
|
||||
public DbSet<NoteHistory> NoteHistories { get; set; }
|
||||
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
|
||||
|
||||
public AppDbContext()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Core.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Data.Configurations
|
||||
{
|
||||
public class NoteConfiguration : IEntityTypeConfiguration<Note>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Note> builder)
|
||||
{
|
||||
builder.HasKey(n => n.Id);
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(n => n.Title);
|
||||
builder.HasIndex(n => n.CreatedAt);
|
||||
builder.HasIndex(n => n.IsPinned);
|
||||
builder.HasIndex(n => n.IsDeleted);
|
||||
|
||||
// Constraints
|
||||
builder.Property(n => n.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(n => n.Content)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
builder.Property(n => n.CreatedBy)
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property(n => n.LastModifiedBy)
|
||||
.HasMaxLength(255);
|
||||
|
||||
// Relationships
|
||||
builder.HasMany(n => n.NoteHistories)
|
||||
.WithOne(h => h.Note)
|
||||
.HasForeignKey(h => h.NoteId)
|
||||
.IsRequired()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Core.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Data.Configurations
|
||||
{
|
||||
public class NoteHistoryConfiguration : IEntityTypeConfiguration<NoteHistory>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NoteHistory> builder)
|
||||
{
|
||||
builder.HasKey(h => h.Id);
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(h => h.NoteId);
|
||||
builder.HasIndex(h => h.ModifiedAt);
|
||||
builder.HasIndex(h => new { h.NoteId, h.ModifiedAt });
|
||||
|
||||
// Constraints
|
||||
builder.Property(h => h.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(h => h.Content)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
builder.Property(h => h.ModifiedBy)
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property(h => h.ChangeType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
// Relationship is configured in NoteConfiguration
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
// <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("20260116235231_AddNoteIsPinnedAndIsDeleted")]
|
||||
partial class AddNoteIsPinnedAndIsDeleted
|
||||
{
|
||||
/// <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("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("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("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 AddNoteIsPinnedAndIsDeleted : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Notes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Content = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = 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),
|
||||
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 =>
|
||||
{
|
||||
table.PrimaryKey("PK_Notes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NoteHistories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
NoteId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Content = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ChangeType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NoteHistories", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NoteHistories_Notes_NoteId",
|
||||
column: x => x.NoteId,
|
||||
principalTable: "Notes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NoteHistories_ModifiedAt",
|
||||
table: "NoteHistories",
|
||||
column: "ModifiedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NoteHistories_NoteId",
|
||||
table: "NoteHistories",
|
||||
column: "NoteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NoteHistories_NoteId_ModifiedAt",
|
||||
table: "NoteHistories",
|
||||
columns: new[] { "NoteId", "ModifiedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notes_CreatedAt",
|
||||
table: "Notes",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notes_IsDeleted",
|
||||
table: "Notes",
|
||||
column: "IsDeleted");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notes_IsPinned",
|
||||
table: "Notes",
|
||||
column: "IsPinned");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notes_Title",
|
||||
table: "Notes",
|
||||
column: "Title");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "NoteHistories");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,93 @@ namespace Data.Migrations
|
||||
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")
|
||||
@@ -283,6 +370,22 @@ namespace Data.Migrations
|
||||
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")
|
||||
@@ -298,6 +401,36 @@ namespace Data.Migrations
|
||||
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)
|
||||
@@ -323,6 +456,17 @@ namespace Data.Migrations
|
||||
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")
|
||||
@@ -375,6 +519,41 @@ namespace Data.Migrations
|
||||
.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");
|
||||
|
||||
+5
-5
@@ -10,11 +10,11 @@
|
||||
<None Remove="Parsers\TestInput\2024 TN TSA State Competition Event Times.txt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.11.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@ namespace WebApp.Authentication
|
||||
public async Task<IActionResult> CookieLogin(
|
||||
[FromForm] string email,
|
||||
[FromForm] string password,
|
||||
[FromForm] bool rememberMe = false)
|
||||
[FromForm] bool rememberMe = false,
|
||||
[FromForm] string? returnUrl = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -42,7 +43,10 @@ namespace WebApp.Authentication
|
||||
ipAddress, remaining);
|
||||
|
||||
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
|
||||
return Redirect($"/login?error={errorMsg}");
|
||||
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||
? $"/login?error={errorMsg}"
|
||||
: $"/login?error={errorMsg}&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
@@ -57,7 +61,10 @@ namespace WebApp.Authentication
|
||||
"Failed login attempt for {Email} from {IpAddress}",
|
||||
email, ipAddress);
|
||||
|
||||
return Redirect("/login?error=Invalid%20email%20or%20password.");
|
||||
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||
? "/login?error=Invalid%20email%20or%20password."
|
||||
: $"/login?error=Invalid%20email%20or%20password.&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// Success - clear rate limit tracking
|
||||
@@ -89,13 +96,22 @@ namespace WebApp.Authentication
|
||||
"Successful login for {Email} ({Role}) from {IpAddress}",
|
||||
result.Email, result.Role, ipAddress);
|
||||
|
||||
// Validate return URL is local to prevent open redirect attacks
|
||||
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
|
||||
{
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
|
||||
return Redirect("/");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during login process");
|
||||
TempData["LoginError"] = "An error occurred. Please try again.";
|
||||
return Redirect("/login");
|
||||
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||
? "/login"
|
||||
: $"/login?returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
@@ -7,6 +7,8 @@
|
||||
<base href="/" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
|
||||
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/easymde.min.css" rel="stylesheet" />
|
||||
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/markdowneditor.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
@@ -17,9 +19,13 @@
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
</AppErrorBoundary>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="@Assets["_content/MudBlazor/MudBlazor.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/markdownEditor.js"></script>
|
||||
<script src="js/markdownTablePaste.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<input type="hidden" id="emailInput" name="email" value="" />
|
||||
<input type="hidden" id="passwordInput" name="password" value="" />
|
||||
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
|
||||
<input type="hidden" id="returnUrlInput" name="returnUrl" value="@_returnUrl" />
|
||||
|
||||
<MudTextField @bind-Value="_loginModel.Email"
|
||||
Label="Email"
|
||||
@@ -75,6 +76,7 @@
|
||||
private MudInputType _passwordInput = MudInputType.Password;
|
||||
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
|
||||
private string _antiforgeryToken = string.Empty;
|
||||
private string? _returnUrl;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -86,13 +88,17 @@
|
||||
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
|
||||
}
|
||||
|
||||
// Check for error message from query parameter (set by controller on failed login)
|
||||
// Check for error message and returnUrl from query parameters
|
||||
var uri = new Uri(Navigation.Uri);
|
||||
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
||||
if (queryParams.TryGetValue("error", out var errorValue))
|
||||
{
|
||||
_errorMessage = errorValue.ToString();
|
||||
}
|
||||
if (queryParams.TryGetValue("returnUrl", out var returnUrlValue))
|
||||
{
|
||||
_returnUrl = returnUrlValue.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private class LoginModel
|
||||
@@ -141,10 +147,12 @@
|
||||
private async Task HandleFormSubmit()
|
||||
{
|
||||
// Update hidden inputs with current model values, then submit the form
|
||||
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
|
||||
await JS.InvokeVoidAsync("eval", $@"
|
||||
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
||||
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
||||
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
||||
document.getElementById('returnUrlInput').value = {returnUrlValue};
|
||||
document.getElementById('loginForm').submit();
|
||||
");
|
||||
}
|
||||
|
||||
@@ -1,79 +1,72 @@
|
||||
@namespace WebApp.Components.Features.Calendar
|
||||
@using Core.Entities
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Event Details</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
@if (EventOccurrence == null)
|
||||
{
|
||||
<MudText>No event data available.</MudText>
|
||||
<MudAlert Severity="Severity.Warning">
|
||||
Event details are unavailable.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="3">
|
||||
@if (EventDefinition != null)
|
||||
<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)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Event</MudText>
|
||||
<MudText Typo="Typo.h6">@EventDefinition.Name</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Occurrence</MudText>
|
||||
<MudText Typo="Typo.body1">@EventOccurrence.Name</MudText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Date & Time</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@EventOccurrence.StartTime.ToString("f")
|
||||
@if (EventOccurrence.EndTime.HasValue)
|
||||
{
|
||||
<text> - @EventOccurrence.EndTime.Value.ToString("t")</text>
|
||||
}
|
||||
<strong>End:</strong> @EventOccurrence.EndTime.Value.ToString("f")
|
||||
</MudText>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(EventOccurrence.Location))
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Location</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.LocationOn" Size="Size.Small" Class="mr-1" />
|
||||
@EventOccurrence.Location
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(EventOccurrence.Time))
|
||||
@if (!string.IsNullOrWhiteSpace(EventOccurrence.Location))
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Time</MudText>
|
||||
<MudText Typo="Typo.body1">@EventOccurrence.Time</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>Location:</strong> @EventOccurrence.Location
|
||||
</MudText>
|
||||
}
|
||||
|
||||
@if (StudentFirstNames != null && StudentFirstNames.Any())
|
||||
@if (StudentFirstNames.Any())
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Students</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.People" Size="Size.Small" Class="mr-1" />
|
||||
@string.Join(", ", StudentFirstNames)
|
||||
</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>Students:</strong> @string.Join(", ", StudentFirstNames)
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudSpacer />
|
||||
<MudButton OnClick="Close">Close</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventOccurrence? EventOccurrence { get; set; }
|
||||
[Parameter] public EventDefinition? EventDefinition { get; set; }
|
||||
[Parameter] public List<string>? StudentFirstNames { get; set; }
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
.SelectMany(list => list)
|
||||
.Select(eo => eo.Location)
|
||||
.Where(loc => !string.IsNullOrWhiteSpace(loc))
|
||||
.Cast<string>() // Cast to non-nullable string after null check
|
||||
.Distinct()
|
||||
.OrderBy(loc => loc)
|
||||
.ToList();
|
||||
|
||||
@@ -5,16 +5,24 @@
|
||||
@using Heron.MudCalendar
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using WebApp.Authentication
|
||||
@inject IEventOccurrenceService EventOccurrenceService
|
||||
@inject ICalendarService CalendarService
|
||||
@inject ILogger<Index> Logger
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar">
|
||||
<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">
|
||||
<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>
|
||||
<PageNoteButton PageIdentifier="Event Calendar" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -27,42 +35,33 @@
|
||||
else
|
||||
{
|
||||
<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"
|
||||
View="_currentView"
|
||||
CurrentDay="@_calendarDate"
|
||||
@bind-View="_currentView"
|
||||
@bind-CurrentDay="_calendarDate"
|
||||
Class="event-calendar"
|
||||
ItemClicked="OnItemClicked">
|
||||
<MonthTemplate>
|
||||
@* <MudTooltip Text="@GetEventTooltip(context)"> *@
|
||||
<div class="d-flex gap-1">
|
||||
<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%;">
|
||||
<MudTooltip Text="@GetEventTooltip(context)">
|
||||
<div class="calendar-event-item">
|
||||
@context.Text
|
||||
</div>
|
||||
@* </MudTooltip> *@
|
||||
</MudTooltip>
|
||||
</MonthTemplate>
|
||||
<WeekTemplate>
|
||||
<MudTooltip Text="@GetEventTooltip(context)">
|
||||
<div class="calendar-event-item">
|
||||
@context.Text
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</WeekTemplate>
|
||||
<DayTemplate>
|
||||
@* <MudTooltip Text="@GetEventTooltip(context)"> *@
|
||||
<div>@context.Text</div>
|
||||
@* </MudTooltip> *@
|
||||
<MudTooltip Text="@GetEventTooltip(context)">
|
||||
<div class="calendar-event-item">
|
||||
@context.Text
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</DayTemplate>
|
||||
</MudCalendar>
|
||||
</MudStack>
|
||||
@@ -70,12 +69,21 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<CalendarEventItem>? _calendarItems;
|
||||
private List<CalendarItemWrapper>? _calendarItems;
|
||||
private DateTime _calendarDate = DateTime.Today;
|
||||
private CalendarView _currentView = CalendarView.Month;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Date { get; set; }
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -84,56 +92,13 @@
|
||||
try
|
||||
{
|
||||
Logger.LogInformation("Loading calendar events");
|
||||
var occurrences = await EventOccurrenceService.GetEventOccurrencesAsync();
|
||||
|
||||
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);
|
||||
|
||||
var items = new List<CalendarEventItem>();
|
||||
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 = StudentFirstNames(occ.EventDefinition, teamsByEventId);
|
||||
|
||||
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();
|
||||
_calendarItems = await CalendarService.GetAllCalendarItemsAsync();
|
||||
Logger.LogInformation("Loaded {Count} calendar items", _calendarItems.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error loading calendar events");
|
||||
_calendarItems = new List<CalendarEventItem>();
|
||||
_calendarItems = [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -141,84 +106,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> StudentFirstNames(EventDefinition ed, Dictionary<int, List<Team>> teamsByEventId)
|
||||
|
||||
|
||||
private string GetEventTooltip(CalendarItemWrapper wrapper)
|
||||
{
|
||||
var studentFirstNames = new List<string>();
|
||||
if (ed?.Id == null || !teamsByEventId.TryGetValue(ed.Id, out var teams)) return studentFirstNames;
|
||||
|
||||
// Get all unique student first names from all teams for this event
|
||||
// Include captain indicator (*) for team events
|
||||
var allStudents = teams
|
||||
.SelectMany(t => t.Students)
|
||||
.DistinctBy(s => s.Id) // Ensure uniqueness by student ID
|
||||
.Select(s =>
|
||||
{
|
||||
var isCaptain = teams.Any(t => t.Captain?.Id == s.Id);
|
||||
var name = s.FirstName;
|
||||
// Add star for captain in team events (EventFormat == Team)
|
||||
if (isCaptain && ed.EventFormat == Core.Entities.EventFormat.Team)
|
||||
{
|
||||
name += "*";
|
||||
}
|
||||
return name;
|
||||
})
|
||||
.OrderBy(name => name)
|
||||
.ToList();
|
||||
|
||||
studentFirstNames = allStudents;
|
||||
|
||||
return studentFirstNames;
|
||||
}
|
||||
|
||||
private DateTime GetNextDateWithEvents()
|
||||
{
|
||||
try
|
||||
if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
|
||||
{
|
||||
if (_calendarItems == null || !_calendarItems.Any())
|
||||
{
|
||||
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;
|
||||
return GetEventTooltip(wrapper.EventItem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
|
||||
{
|
||||
Logger.LogError(ex, "Error in GetNextDateWithEvents");
|
||||
return DateTime.Today;
|
||||
return GetMeetingTooltip(wrapper.MeetingItem);
|
||||
}
|
||||
return wrapper.Text;
|
||||
}
|
||||
|
||||
private string GetEventTooltip(CalendarEventItem item)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
List<string> parts = [];
|
||||
|
||||
if (!string.IsNullOrEmpty(item.EventDefinition?.Name))
|
||||
{
|
||||
@@ -248,12 +153,27 @@
|
||||
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)
|
||||
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)
|
||||
@@ -277,5 +197,25 @@
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -38,6 +38,10 @@ else
|
||||
Click on a node to see details. Use mouse to zoom and pan the graph.
|
||||
</MudText>
|
||||
|
||||
<div style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<Network Id="careerMappingNetwork" Data="@_networkData" Options="@GetNetworkOptions" OnClick="HandleNetworkClick" />
|
||||
</div>
|
||||
|
||||
@if (_selectedNodeInfo != null)
|
||||
{
|
||||
<MudPaper Elevation="1" Class="pa-4 mb-4" Style="background-color: #f5f5f5;">
|
||||
@@ -49,19 +53,18 @@ else
|
||||
@if (_selectedNodeInfo.Careers != null && _selectedNodeInfo.Careers.Any())
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Related Careers:</MudText>
|
||||
<MudStack Row="true" Spacing="1" WrapItems="true">
|
||||
@foreach (var career in _selectedNodeInfo.Careers.OrderBy(c => c))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="Color.Default">@career</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
<div class="career-mapping">
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
@foreach (var career in _selectedNodeInfo.Careers.OrderBy(c => c))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="Color.Default">@career</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<div style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<Network Id="careerMappingNetwork" Data="@_networkData" Options="@GetNetworkOptions" OnClick="HandleNetworkClick"/>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@@ -115,7 +118,7 @@ else
|
||||
{
|
||||
if (!_fieldIdToCareers.ContainsKey(field.Id))
|
||||
{
|
||||
_fieldIdToCareers[field.Id] = new List<string>();
|
||||
_fieldIdToCareers[field.Id] = [];
|
||||
}
|
||||
// Add unique career names for this field
|
||||
foreach (var career in evt.RelatedCareers)
|
||||
@@ -150,8 +153,8 @@ else
|
||||
|
||||
private NetworkData GenerateNetworkData(List<EventDefinition> events)
|
||||
{
|
||||
var nodes = new List<Node>();
|
||||
var edges = new List<Edge>();
|
||||
List<Node> nodes = [];
|
||||
List<Edge> edges = [];
|
||||
|
||||
// Dictionary to track node IDs (to avoid duplicates)
|
||||
var eventNodeIds = new Dictionary<int, string>();
|
||||
@@ -281,7 +284,7 @@ else
|
||||
Title = field.Name,
|
||||
Description = field.Description,
|
||||
IsCareerField = true,
|
||||
Careers = careers?.ToList() ?? new List<string>()
|
||||
Careers = careers?.ToList() ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
@using WebApp.Models
|
||||
@using WebApp.Models
|
||||
|
||||
@if (EventDefinition is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@* @if (EventDefinition.LevelOfEffort.HasValue)
|
||||
{
|
||||
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
|
||||
} *@
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Filled" Style="background-color: white; font-family: monospace; white-space: pre;">
|
||||
<MudChip T="string" Color="Color.Default" Variant="@AppIcons.EventChipVariant()" Style="background-color: white; font-family: monospace; white-space: pre;">
|
||||
@{
|
||||
var loeIcon = AppIcons.LevelOfEffortIcon(EventDefinition.LevelOfEffort);
|
||||
var loeColor = AppIcons.IconColors.GetValueOrDefault(loeIcon, "inherit");
|
||||
@@ -27,12 +31,17 @@
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required EventDefinition EventDefinition { get; set; }
|
||||
public EventDefinition? EventDefinition { get; set; }
|
||||
|
||||
private string _attributes = string.Empty;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (EventDefinition is null)
|
||||
{
|
||||
_attributes = string.Empty;
|
||||
return;
|
||||
}
|
||||
_attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " ";
|
||||
_attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " ";
|
||||
_attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " ";
|
||||
|
||||
@@ -19,10 +19,14 @@
|
||||
BackButtonUrl="@(ReturnUrl ?? "/events")">
|
||||
<ActionButtons>
|
||||
<div class="no-print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||
<MudTooltip Text="Print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
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>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/events/edit"
|
||||
@page "/events/edit"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -163,9 +163,10 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the tracked entity from the database
|
||||
// Get the tracked entity from the database (do not Include RelatedCareers:
|
||||
// the same context may already be tracking those Career instances from the initial load,
|
||||
// which would cause "another instance with the same key value is already being tracked").
|
||||
var trackedEntity = await context.Events
|
||||
.Include(e => e.RelatedCareers)
|
||||
.FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
|
||||
|
||||
if (trackedEntity == null)
|
||||
@@ -177,6 +178,8 @@
|
||||
|
||||
// Update scalar properties from the form-bound entity
|
||||
context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!);
|
||||
// RelatedCareersText is not mapped; copy it so ProcessRelatedCareersAsync can use it
|
||||
trackedEntity.RelatedCareersText = EventDefinition!.RelatedCareersText;
|
||||
|
||||
// Normalize and process related careers
|
||||
await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/events"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -9,9 +10,15 @@
|
||||
|
||||
<PageHeader Title="Events">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton>
|
||||
<MudTooltip Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</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>
|
||||
</PageHeader>
|
||||
|
||||
@@ -30,7 +37,7 @@
|
||||
<PropertyColumn Property="@(e => e.Name)" Title="Event Name" Sortable="true">
|
||||
<CellTemplate>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
<MudLink Href="@($"/events/details?id={context.Item.Id}")"
|
||||
<MudLink Href="@($"/events/details?id={context.Item.Id}&returnUrl=/events")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
@context.Item.Name
|
||||
@@ -38,7 +45,7 @@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||
TooltipText="Edit"
|
||||
Href="@($"/events/edit?id={context.Item.Id}")" />
|
||||
Href="@($"/events/edit?id={context.Item.Id}&returnUrl=/events")" />
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
||||
TooltipText="Delete"
|
||||
HoverColor="Color.Error"
|
||||
@@ -74,16 +81,32 @@
|
||||
@code {
|
||||
MudDataGrid<EventDefinition> _dataGrid = null!;
|
||||
private bool _isLoading = true;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
private async Task<GridData<EventDefinition>> ServerReload(GridState<EventDefinition> state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var query = Context.Events.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
var totalItems = await query.CountAsync();
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
|
||||
var query = Context.Events
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||
|
||||
var totalItems = await query.CountAsync(cancellationToken);
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
|
||||
|
||||
return new GridData<EventDefinition>
|
||||
{
|
||||
@@ -91,31 +114,97 @@
|
||||
Items = pagedData
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteEventDefinition(EventDefinition evt)
|
||||
{
|
||||
//_isRowBlocked = true;
|
||||
if (_isDisposed) return;
|
||||
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete Event",
|
||||
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (result == true)
|
||||
try
|
||||
{
|
||||
Context.Events.Remove(evt!);
|
||||
await Context.SaveChangesAsync();
|
||||
Snackbar.Add($"Delete event: Delete of Event {evt.Name}", Severity.Info);
|
||||
}
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete Event",
|
||||
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
// Load the event fresh from database with tracking to avoid tracking conflicts
|
||||
var eventToDelete = await Context.Events
|
||||
.FirstOrDefaultAsync(e => e.Id == evt.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (eventToDelete == null)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add("Event not found or already deleted", Severity.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Context.Events.Remove(eventToDelete);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Event {eventToDelete.Name} deleted", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting event: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +1,45 @@
|
||||
@page "/meeting-schedule"
|
||||
@page "/meeting-schedule"
|
||||
@attribute [Authorize]
|
||||
@using System.Text
|
||||
@using Core.Calculation
|
||||
@using Core.Models
|
||||
@using Core.Utility
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using WebApp.Components.Features.MeetingSchedule
|
||||
@using WebApp.Models
|
||||
@using WebApp.Services
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
@inject ClipboardService ClipboardService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMeetingScheduleStateService StateService
|
||||
@inject IMeetingScheduleDataService DataService
|
||||
@inject IMeetingScheduleClipboardService ClipboardFormatService
|
||||
@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">
|
||||
<MudGrid>
|
||||
@@ -17,40 +47,93 @@
|
||||
<MudText Typo="Typo.h4">Time Slots</MudText>
|
||||
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
||||
<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">
|
||||
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
|
||||
</MudTooltip>
|
||||
<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">
|
||||
<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 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 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 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 xs="12" sm="6" lg="4">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
||||
</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">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
|
||||
<MudTooltip Text="Copy to Clipboard">
|
||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="Copy to Clipboard">
|
||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
||||
OnClick="Solve"
|
||||
Color="Color.Primary"
|
||||
Disabled="@_isSolving">
|
||||
Solve
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
@@ -62,11 +145,14 @@
|
||||
<MudGrid>
|
||||
<MudItem xs="12" lg="6">
|
||||
<ScheduledTeamsList TimeSlotName="@context.Name"
|
||||
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
|
||||
Teams="@context.Teams"
|
||||
ScheduledTeams="@_scheduledTeams"
|
||||
AbsentStudents="@_absentStudents"
|
||||
StudentHasOverlaps="@context.StudentHasOverlaps"
|
||||
OnToggleTeam="@ToggleRequiredTeam" />
|
||||
ExcludedStudents="@_excludedStudents"
|
||||
OnToggleTeam="@ToggleRequiredTeam"
|
||||
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" lg="6">
|
||||
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
||||
@@ -90,11 +176,13 @@
|
||||
Label="Search for absent students"
|
||||
ShowFullName="true"/>
|
||||
<MudDivider Class="my-4"/>
|
||||
<TeamToggleSelector Teams="@_teams"
|
||||
SelectedTeams="_scheduledTeams"
|
||||
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
||||
Title="Scheduled Teams"
|
||||
ShowEventAttributes="true" />
|
||||
<TeamMeetingToggleSelector Teams="@_teams"
|
||||
SelectedTeams="_scheduledTeams"
|
||||
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
||||
ExtendedTeams="_extendedTeams"
|
||||
ExtendedTeamsChanged="OnExtendedTeamsChanged"
|
||||
Title="Scheduled Teams"
|
||||
ShowEventAttributes="false" />
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@@ -108,79 +196,158 @@
|
||||
private TeamSchedulerSolution _solution = null!;
|
||||
private TeamSchedulerOptions _parameters = null!;
|
||||
bool _isSolving;
|
||||
private bool _isLoadingClipboard = false;
|
||||
private IEnumerable<Team> _scheduledTeams = [];
|
||||
private IEnumerable<Student> _absentStudents = [];
|
||||
private IEnumerable<Team> _possibleAdditions = [];
|
||||
private IEnumerable<Team> _extendedTeams = [];
|
||||
// Key: (teamId, timeSlotIndex, studentId) - Value: true if excluded
|
||||
private Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> _excludedStudents = new();
|
||||
// Track last saved state for dirty/clean comparison
|
||||
private MeetingScheduleState? _lastSavedState;
|
||||
|
||||
private async Task OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
||||
private void OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
||||
{
|
||||
_scheduledTeams = teams;
|
||||
await SaveScheduledTeams();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task OnAbsentStudentsChanged(IEnumerable<Student> students)
|
||||
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
|
||||
{
|
||||
_absentStudents = students;
|
||||
await SaveAbsentStudents();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task OnTimeSlotCountChanged(int timeSlots)
|
||||
{
|
||||
_parameters.TimeSlots = timeSlots;
|
||||
await SaveTimeSlotCount();
|
||||
StateHasChanged();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void AddRegionals()
|
||||
private async Task IncrementTimeSlots()
|
||||
{
|
||||
_scheduledTeams
|
||||
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
||||
await SaveScheduledTeams();
|
||||
if (_parameters.TimeSlots < 4)
|
||||
{
|
||||
await OnTimeSlotCountChanged(_parameters.TimeSlots + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async void AddHighLevelOfEffort()
|
||||
private async Task DecrementTimeSlots()
|
||||
{
|
||||
_scheduledTeams
|
||||
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
||||
await SaveScheduledTeams();
|
||||
if (_parameters.TimeSlots > 1)
|
||||
{
|
||||
await OnTimeSlotCountChanged(_parameters.TimeSlots - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async void RemoveIndividual()
|
||||
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
|
||||
{
|
||||
_scheduledTeams
|
||||
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
||||
await SaveScheduledTeams();
|
||||
_extendedTeams = teams;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void RemoveLowLevelOfEffort()
|
||||
private void AddRegionals()
|
||||
{
|
||||
_scheduledTeams
|
||||
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
||||
await SaveScheduledTeams();
|
||||
_scheduledTeams = _scheduledTeams.AddRegionals(_teams);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void Invert()
|
||||
private void AddPresubmission()
|
||||
{
|
||||
var rt = _scheduledTeams.ToArray();
|
||||
_scheduledTeams
|
||||
= _teams.Where(t => !rt.Contains(t));
|
||||
await SaveScheduledTeams();
|
||||
var presubmissionTeams = _teams.Where(t => t.Event?.Presubmission == true);
|
||||
_scheduledTeams = _scheduledTeams.Concat(presubmissionTeams).Distinct();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void Reset()
|
||||
private void AddHighLevelOfEffort()
|
||||
{
|
||||
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
|
||||
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();
|
||||
}
|
||||
|
||||
private void RemoveIndividual()
|
||||
{
|
||||
_scheduledTeams = _scheduledTeams.RemoveIndividual();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void AddLowLevelOfEffort()
|
||||
{
|
||||
var lowEffortTeams = _teams.Where(t => t.Event.LevelOfEffort <= 1);
|
||||
_scheduledTeams = _scheduledTeams.Concat(lowEffortTeams).Distinct();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void RemoveLowLevelOfEffort()
|
||||
{
|
||||
_scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void Invert()
|
||||
{
|
||||
_scheduledTeams = _scheduledTeams.Invert(_teams);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
_scheduledTeams = [];
|
||||
await SaveScheduledTeams();
|
||||
_extendedTeams = [];
|
||||
_excludedStudents.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void ToggleRequiredTeam(Team unassignedTeam)
|
||||
private void ToggleRequiredTeam(Team unassignedTeam)
|
||||
{
|
||||
if (_scheduledTeams.Contains(unassignedTeam))
|
||||
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam);
|
||||
// Find the matching team from _teams to ensure reference equality with MudToggleGroup
|
||||
var matchingTeam = _teams.FirstOrDefault(t => t.Id == unassignedTeam.Id);
|
||||
if (matchingTeam == null) return;
|
||||
|
||||
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
|
||||
IEnumerable<Team> newScheduledTeams;
|
||||
|
||||
if (scheduledTeamIds.Contains(matchingTeam.Id))
|
||||
newScheduledTeams = _scheduledTeams.Where(t => t.Id != matchingTeam.Id);
|
||||
else
|
||||
{
|
||||
_scheduledTeams = _scheduledTeams.Concat(new[] { unassignedTeam });
|
||||
newScheduledTeams = _scheduledTeams.Concat([matchingTeam]);
|
||||
}
|
||||
await SaveScheduledTeams();
|
||||
|
||||
// Update state and notify component to re-render
|
||||
OnScheduledTeamsChanged(newScheduledTeams);
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -214,70 +381,82 @@
|
||||
]
|
||||
);
|
||||
|
||||
_teams
|
||||
= await Context.Teams
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Students)
|
||||
.OrderBy(e => e.Event.Name)
|
||||
.ThenBy(e => e.Identifier)
|
||||
.ToArrayAsync();
|
||||
|
||||
_students =
|
||||
await Context.Students
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Captain)
|
||||
.Include(e => e.EventRankings)
|
||||
.ThenInclude(e => e.EventDefinition)
|
||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
||||
_teams = await DataService.LoadTeamsAsync();
|
||||
_students = await DataService.LoadStudentsAsync();
|
||||
|
||||
// Load saved selections from localStorage
|
||||
await LoadScheduledTeams();
|
||||
await LoadAbsentStudents();
|
||||
await LoadTimeSlotCount();
|
||||
}
|
||||
_scheduledTeams = await StateService.LoadScheduledTeamsAsync(_teams);
|
||||
_absentStudents = await StateService.LoadAbsentStudentsAsync(_students);
|
||||
_parameters.TimeSlots = await StateService.LoadTimeSlotCountAsync(2);
|
||||
_extendedTeams = await StateService.LoadExtendedTeamsAsync(_teams);
|
||||
_excludedStudents = await StateService.LoadExcludedStudentsAsync();
|
||||
|
||||
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)
|
||||
// Initialize last saved state from loaded values
|
||||
_lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students);
|
||||
if (_lastSavedState == null)
|
||||
{
|
||||
_scheduledTeams = _teams.Where(t => teamIds.Contains(t.Id)).ToArray();
|
||||
// If no saved state exists, create initial state from current values
|
||||
_lastSavedState = MeetingScheduleState.FromCurrent(
|
||||
_scheduledTeams,
|
||||
_absentStudents,
|
||||
_parameters.TimeSlots,
|
||||
_extendedTeams,
|
||||
_excludedStudents);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAbsentStudents()
|
||||
{
|
||||
var studentIds = _absentStudents.Select(s => s.Id).ToArray();
|
||||
await LocalStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", studentIds);
|
||||
}
|
||||
|
||||
private async Task LoadAbsentStudents()
|
||||
private int GetTimeSlotIndex(string timeSlotName)
|
||||
{
|
||||
var studentIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents");
|
||||
if (studentIds.Length > 0)
|
||||
if (_solution?.TimeSlots == null)
|
||||
return 0; // Default to first slot if solution not available
|
||||
|
||||
for (int i = 0; i < _solution.TimeSlots.Length; i++)
|
||||
{
|
||||
_absentStudents = _students.Where(s => studentIds.Contains(s.Id)).ToArray();
|
||||
if (_solution.TimeSlots[i].Name == timeSlotName)
|
||||
return i;
|
||||
}
|
||||
return 0; // Default to first slot if not found (shouldn't happen in normal flow)
|
||||
}
|
||||
|
||||
private async Task SaveTimeSlotCount()
|
||||
private void OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key)
|
||||
{
|
||||
await LocalStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", _parameters.TimeSlots);
|
||||
}
|
||||
|
||||
private async Task LoadTimeSlotCount()
|
||||
{
|
||||
var timeSlots = await LocalStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2);
|
||||
if (timeSlots > 0)
|
||||
if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded)
|
||||
{
|
||||
_parameters.TimeSlots = timeSlots;
|
||||
_excludedStudents.Remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_excludedStudents[key] = true;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
|
||||
{
|
||||
var key = (teamId, timeSlotIndex, studentId);
|
||||
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
||||
}
|
||||
|
||||
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
|
||||
{
|
||||
var absentStudentIds = _absentStudents.Select(s => s.Id).ToHashSet();
|
||||
return OverlapCalculationHelper.GetTeamsWithoutExcludedStudents(teams, timeSlotIndex, _excludedStudents, absentStudentIds);
|
||||
}
|
||||
|
||||
private bool IsDirty()
|
||||
{
|
||||
if (_lastSavedState == null)
|
||||
return true;
|
||||
|
||||
var currentState = MeetingScheduleState.FromCurrent(
|
||||
_scheduledTeams,
|
||||
_absentStudents,
|
||||
_parameters.TimeSlots,
|
||||
_extendedTeams,
|
||||
_excludedStudents);
|
||||
|
||||
return !currentState.Equals(_lastSavedState);
|
||||
}
|
||||
|
||||
private async Task<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
|
||||
@@ -306,23 +485,89 @@
|
||||
// Update parameters with absent student names
|
||||
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
|
||||
|
||||
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents);
|
||||
// Update parameters with extended team event names
|
||||
_parameters.ExtendedTeams = _extendedTeams
|
||||
.Where(t => t.Event != null)
|
||||
.Select(t => t.Event!.Name)
|
||||
.ToArray();
|
||||
|
||||
// Create PartialTeam instances for teams with excluded students
|
||||
var teamsForScheduling = PartialTeam.CreatePartialTeamsFromExclusions(_scheduledTeams, _excludedStudents);
|
||||
|
||||
var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
|
||||
_solution = teamScheduler.Solve();
|
||||
|
||||
// Restore full teams (with all students) in the solution so excluded students still appear (dimmed)
|
||||
// Create a mapping from PartialTeam to original Team
|
||||
var teamMapping = _scheduledTeams.ToDictionary(t => t.Id, t => t);
|
||||
|
||||
for (int slotIndex = 0; slotIndex < _solution.TimeSlots.Length; slotIndex++)
|
||||
{
|
||||
var slot = _solution.TimeSlots[slotIndex];
|
||||
if (slot.Teams == null)
|
||||
continue;
|
||||
|
||||
var restoredTeams = slot.Teams.Select(team =>
|
||||
{
|
||||
// If this is a PartialTeam or we have the original, restore it
|
||||
if (teamMapping.TryGetValue(team.Id, out var originalTeam))
|
||||
{
|
||||
return originalTeam;
|
||||
}
|
||||
return team;
|
||||
}).ToArray();
|
||||
|
||||
slot.Teams = restoredTeams;
|
||||
|
||||
// Recalculate overlaps and unscheduled students with full teams
|
||||
// Filter out excluded students when calculating overlaps and unscheduled students
|
||||
var teamsForOverlapCalculation = GetTeamsWithoutExcludedStudents(slot.Teams, slotIndex);
|
||||
slot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(teamsForOverlapCalculation);
|
||||
// Use teams without excluded students so students excluded from all teams appear as unscheduled
|
||||
slot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(teamsForOverlapCalculation, availableStudents);
|
||||
}
|
||||
|
||||
// Post-process: extend teams to next consecutive time slot
|
||||
if (_extendedTeams.Any())
|
||||
{
|
||||
TeamSchedulerPostProcessor.ExtendTeamsInSolution(
|
||||
_solution,
|
||||
_extendedTeams,
|
||||
availableStudents,
|
||||
GetTeamsWithoutExcludedStudents);
|
||||
}
|
||||
|
||||
// Try recommendation strategies in priority order
|
||||
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
||||
var strategies = new[]
|
||||
{
|
||||
UnassignedScheduleStrategy[] strategies =
|
||||
[
|
||||
UnassignedScheduleStrategy.LevelOfEffort,
|
||||
UnassignedScheduleStrategy.BiggestGroup,
|
||||
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
||||
UnassignedScheduleStrategy.IndividualEvents
|
||||
};
|
||||
];
|
||||
|
||||
_possibleAdditions = strategies
|
||||
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
||||
.FirstOrDefault(result => result.Any()) ?? [];
|
||||
|
||||
// Save state to localStorage after solving completes successfully
|
||||
var currentState = MeetingScheduleState.FromCurrent(
|
||||
_scheduledTeams,
|
||||
_absentStudents,
|
||||
_parameters.TimeSlots,
|
||||
_extendedTeams,
|
||||
_excludedStudents);
|
||||
await currentState.SaveToLocalStorage(LocalStorage);
|
||||
_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
|
||||
|
||||
_isSolving = false;
|
||||
@@ -334,19 +579,18 @@
|
||||
_solutionData.ReloadServerData();
|
||||
}
|
||||
|
||||
|
||||
async Task CopyToClipboard()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var timeslot in _solution.TimeSlots)
|
||||
{
|
||||
AppendScheduledTeams(sb, timeslot);
|
||||
AppendUnscheduledStudents(sb, timeslot);
|
||||
sb.Append(Environment.NewLine);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ClipboardService.WriteTextAsync(sb.ToString());
|
||||
var text = ClipboardFormatService.FormatScheduleForClipboard(
|
||||
_solution,
|
||||
_teams,
|
||||
_absentStudents,
|
||||
_excludedStudents,
|
||||
GetTimeSlotIndex);
|
||||
await ClipboardService.WriteTextAsync(text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -354,64 +598,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
||||
private async Task OpenSaveHistoryDialog()
|
||||
{
|
||||
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
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
|
||||
{
|
||||
var studentsList = FormatStudentList(scheduledTeam, timeslot);
|
||||
sb.Append($"{teamName} - {studentsList}");
|
||||
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
|
||||
}
|
||||
sb.Append(Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot)
|
||||
{
|
||||
return string.Join(", ",
|
||||
team.Students
|
||||
.OrderBy(e => e == team.Captain)
|
||||
.ThenBy(e => e.FirstName)
|
||||
.Select(e => FormatStudentName(e, timeslot)));
|
||||
}
|
||||
|
||||
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
|
||||
{
|
||||
var name = student.FirstName;
|
||||
if (timeslot.StudentHasOverlaps(student))
|
||||
name += "*";
|
||||
if (_absentStudents.Contains(student))
|
||||
name += " (absent)";
|
||||
return name;
|
||||
}
|
||||
|
||||
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)
|
||||
catch (JSException ex)
|
||||
{
|
||||
var studentName = student.FirstName;
|
||||
if (_absentStudents.Contains(student))
|
||||
studentName += " (absent)";
|
||||
|
||||
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
||||
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
||||
|
||||
sb.Append($"{studentName} - {teamsList}");
|
||||
sb.Append(Environment.NewLine);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,93 @@
|
||||
@using Core.Calculation
|
||||
@using WebApp.Models
|
||||
@using Core.Utility
|
||||
|
||||
<MudStack>
|
||||
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
|
||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
||||
{
|
||||
var removed = !ScheduledTeams.Contains(team);
|
||||
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
|
||||
var removed = !scheduledTeamIds.Contains(team.Id);
|
||||
|
||||
<MudLink Typo="Typo.body1"
|
||||
Class="d-flex align-center"
|
||||
Color="Color.Default"
|
||||
OnClick="@(() => OnToggleTeam.InvokeAsync(team))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
||||
Size="Size.Small"
|
||||
Class="@(removed ? "" : "d-none")">
|
||||
</MudIcon>
|
||||
@team -
|
||||
@foreach (var student in team.Students)
|
||||
{
|
||||
var overlap = StudentHasOverlaps(student);
|
||||
var isAbsent = AbsentStudents.Contains(student);
|
||||
var color = overlap ? Color.Warning : Color.Default;
|
||||
var suffix = GetStudentSuffix(overlap, isAbsent);
|
||||
|
||||
if (student != team.Students.First())
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
|
||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
||||
Size="Size.Small"
|
||||
Class="@(removed ? "" : "d-none")"
|
||||
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
|
||||
Style="cursor: pointer;">
|
||||
</MudIcon>
|
||||
@{
|
||||
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
});
|
||||
}
|
||||
<MudText Typo="Typo.body2" Color="@color">
|
||||
@student.FirstName@suffix
|
||||
</MudText>
|
||||
}
|
||||
</MudLink>
|
||||
<MudTooltip Text="@teamMembers">
|
||||
<div @onclick="@(() => OnToggleTeam.InvokeAsync(team))" style="cursor: pointer; display: inline-block;">
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
Variant="@AppIcons.TeamChipVariant()">
|
||||
@team
|
||||
</MudChip>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
@{
|
||||
var nonExcludedStudentCount = GetNonExcludedStudentCount(team);
|
||||
}
|
||||
@foreach (var student in team.Students)
|
||||
{
|
||||
var overlap = StudentHasOverlaps(student);
|
||||
var chipColor = overlap ? Color.Warning : Color.Default;
|
||||
var isExcluded = IsStudentExcluded(team.Id, TimeSlotIndex, student.Id);
|
||||
var formattedName = TeamStudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
MarkOverlaps = true,
|
||||
HasOverlaps = StudentHasOverlaps,
|
||||
MarkAbsent = true,
|
||||
AbsentStudents = AbsentStudents.ToList()
|
||||
});
|
||||
|
||||
@if (nonExcludedStudentCount > 1)
|
||||
{
|
||||
<InteractiveChip Size="Size.Small"
|
||||
Color="@chipColor"
|
||||
Variant="@AppIcons.StudentChipVariant()"
|
||||
Style="@(isExcluded ? "opacity: 0.5;" : "")">
|
||||
<ChildContent>
|
||||
<span>@formattedName</span>
|
||||
</ChildContent>
|
||||
<ControlContent>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
Variant="Variant.Text"
|
||||
OnClick="@(() => OnToggleStudentExclusion.InvokeAsync((team.Id, TimeSlotIndex, student.Id)))"
|
||||
Style="padding: 0; min-width: 16px; width: 16px; height: 16px; margin-left: 4px;" />
|
||||
</ControlContent>
|
||||
</InteractiveChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@chipColor"
|
||||
Variant="@AppIcons.StudentChipVariant()"
|
||||
Style="@(isExcluded ? "opacity: 0.5;" : "")">
|
||||
<span>@formattedName</span>
|
||||
</MudChip>
|
||||
}
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -39,6 +95,9 @@
|
||||
[Parameter]
|
||||
public string TimeSlotName { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public int TimeSlotIndex { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IEnumerable<Team> Teams { get; set; } = [];
|
||||
|
||||
@@ -51,13 +110,23 @@
|
||||
[Parameter]
|
||||
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> ExcludedStudents { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Team> OnToggleTeam { get; set; }
|
||||
|
||||
private string GetStudentSuffix(bool overlap, bool isAbsent)
|
||||
[Parameter]
|
||||
public EventCallback<(int teamId, int timeSlotIndex, int studentId)> OnToggleStudentExclusion { get; set; }
|
||||
|
||||
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
|
||||
{
|
||||
var suffix = overlap ? "*" : "";
|
||||
suffix += isAbsent ? " (absent)" : "";
|
||||
return suffix;
|
||||
var key = (teamId, timeSlotIndex, studentId);
|
||||
return ExcludedStudents.ContainsKey(key) && ExcludedStudents[key];
|
||||
}
|
||||
|
||||
private int GetNonExcludedStudentCount(Team team)
|
||||
{
|
||||
return team.Students.Count(s => !IsStudentExcluded(team.Id, TimeSlotIndex, s.Id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@using Core.Calculation
|
||||
@using Core.Utility
|
||||
@using WebApp.Models
|
||||
|
||||
@if (UnscheduledStudents.Any())
|
||||
{
|
||||
@@ -6,31 +8,52 @@
|
||||
<MudStack>
|
||||
@foreach (var student in UnscheduledStudents)
|
||||
{
|
||||
var isAbsent = AbsentStudents.Contains(student);
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.body1" HtmlTag="i">
|
||||
@student.FirstName@(isAbsent ? " (absent)" : "")
|
||||
</MudText>
|
||||
@foreach (var unassignedTeam in UnassignedTeams(student))
|
||||
{
|
||||
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
||||
var isScheduled = ScheduledTeams.Contains(unassignedTeam);
|
||||
var color = isPossibleAddition ? Color.Success : Color.Default;
|
||||
|
||||
if (unassignedTeam != UnassignedTeams(student).First())
|
||||
{
|
||||
<span>, </span>
|
||||
}
|
||||
<MudLink Typo="Typo.body2"
|
||||
Color="@color"
|
||||
OnClick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Check"
|
||||
Size="Size.Small"
|
||||
Class="@(isScheduled ? "" : "d-none")">
|
||||
</MudIcon>
|
||||
@unassignedTeam
|
||||
</MudLink>
|
||||
@{
|
||||
var formattedName = StudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
new StudentNameFormatter.FormatOptions
|
||||
{
|
||||
IsAbsent = AbsentStudents.Contains(student)
|
||||
});
|
||||
}
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||
<MudChip T="string" Size="Size.Small" Variant="@AppIcons.StudentChipVariant()" Class="font-style-italic">
|
||||
@formattedName
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
@foreach (var unassignedTeam in UnassignedTeams(student))
|
||||
{
|
||||
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
||||
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
|
||||
var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id);
|
||||
var chipColor = isPossibleAddition ? Color.Success : Color.Default;
|
||||
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||
unassignedTeam,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
});
|
||||
|
||||
<MudTooltip Text="@teamMembers">
|
||||
<div @onclick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))" style="cursor: pointer; display: inline-block;">
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@chipColor"
|
||||
Variant="@AppIcons.TeamChipVariant()">
|
||||
@if (isScheduled)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Style="margin-right: 4px;" />
|
||||
}
|
||||
@unassignedTeam
|
||||
</MudChip>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
BackButtonUrl="@(ReturnUrl ?? "/students")">
|
||||
<ActionButtons>
|
||||
<div class="no-print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||
<MudTooltip Text="Print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
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>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/students/event-ranking"
|
||||
@page "/students/event-ranking"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@@ -9,7 +9,11 @@
|
||||
|
||||
<PageHeader
|
||||
Title="Student Event Ranks"
|
||||
Icon="@AppIcons.EventRank" />
|
||||
Icon="@AppIcons.EventRank">
|
||||
<ActionButtons>
|
||||
<PageNoteButton PageIdentifier="Event Ranking" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@if (_students == null)
|
||||
{
|
||||
@@ -140,11 +144,13 @@ else
|
||||
.OrderBy(e => e.Event.Name)
|
||||
.ToArray();
|
||||
|
||||
var events = await Context.Events.ToArrayAsync();
|
||||
var events = await Context.Events
|
||||
.AsNoTracking()
|
||||
.ToArrayAsync();
|
||||
var remainingEvents =
|
||||
events
|
||||
.Where(e => _eventStudentRankings.All(est => est.Event.Id != e.Id))
|
||||
.Select(e => new EventStudentRankings { Event = e, StudentRanking = Array.Empty<Tuple<Student, int>>() })
|
||||
.Select(e => new EventStudentRankings { Event = e, StudentRanking = [] })
|
||||
.OrderBy(e => e.Event.Name)
|
||||
.ToArray();
|
||||
|
||||
|
||||
@@ -22,12 +22,14 @@ else
|
||||
ShowBackButton="true"
|
||||
BackButtonUrl="/students/event-ranking">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="Save"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Filled">
|
||||
Save Rankings
|
||||
</MudButton>
|
||||
<MudTooltip Text="Save Rankings">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="Save"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Filled">
|
||||
Save Rankings
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/students"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -9,9 +10,15 @@
|
||||
|
||||
<PageHeader Title="Students">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
|
||||
<MudButton StartIcon="@AppIcons.Registration" Href="students/teams" Variant="Variant.Outlined">Registration</MudButton>
|
||||
<MudTooltip Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</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>
|
||||
</PageHeader>
|
||||
|
||||
@@ -30,7 +37,7 @@
|
||||
<PropertyColumn Property="@(e => e.LastName)" Title="Name" Sortable="true">
|
||||
<CellTemplate>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 0.25rem;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.Wrap">
|
||||
<MudLink Href="@($"/students/details?id={context.Item.Id}&returnUrl=/students")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
@@ -40,7 +47,7 @@
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
|
||||
}
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||
TooltipText="Edit"
|
||||
@@ -68,18 +75,34 @@
|
||||
@code {
|
||||
MudDataGrid<Student> _dataGrid = null!;
|
||||
private bool _isLoading = true;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
var query =
|
||||
Context.Students.OrderBy(e => e.LastName)
|
||||
Context.Students
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.LastName)
|
||||
.Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||
|
||||
var totalItems = await query.CountAsync();
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
|
||||
var totalItems = await query.CountAsync(cancellationToken);
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
|
||||
|
||||
return new GridData<Student>
|
||||
{
|
||||
@@ -87,31 +110,97 @@
|
||||
Items = pagedData
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteStudent(Student student)
|
||||
{
|
||||
//_isRowBlocked = true;
|
||||
if (_isDisposed) return;
|
||||
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete student",
|
||||
(MarkupString)$"Are you sure want to delete <b>{student.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (result == true)
|
||||
try
|
||||
{
|
||||
Context.Students.Remove(student!);
|
||||
await Context.SaveChangesAsync();
|
||||
Snackbar.Add($"Delete event: Delete of Student {student.Name}", Severity.Info);
|
||||
}
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete student",
|
||||
(MarkupString)$"Are you sure want to delete <b>{student.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
// Load the student fresh from database with tracking to avoid tracking conflicts
|
||||
var studentToDelete = await Context.Students
|
||||
.FirstOrDefaultAsync(s => s.Id == student.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (studentToDelete == null)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add("Student not found or already deleted", Severity.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Context.Students.Remove(studentToDelete);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Student {studentToDelete.Name} deleted", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting student: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,22 @@
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Validation
|
||||
@using Core.Utility
|
||||
@inject AppDbContext Context
|
||||
@inject WebApp.LocalStorageService LocalStorage
|
||||
@inject ValidationService ValidationService
|
||||
|
||||
<PageHeader Title="Registration" Icon="@AppIcons.Registration">
|
||||
<ActionButtons>
|
||||
<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 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="Registration" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -79,16 +83,22 @@
|
||||
? context.Item.Teams.Where(t => t?.Event is { RegionalEvent: true }).OrderBy(t => t.Event.Name)
|
||||
: context.Item.Teams.Where(t => t?.Event != null).OrderBy(t => t.Event.Name);
|
||||
}
|
||||
<div class="d-flex flex-wrap" style="gap: 0.25rem;">
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
@foreach (var team in teamsToDisplay)
|
||||
{
|
||||
var isCaptain = team.Captain != null && team.Captain.Equals(context.Item.Student);
|
||||
var teamMembers = string.Join(", ", team.Students.Select(s => s.FirstName));
|
||||
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
});
|
||||
|
||||
<MudTooltip Text="@teamMembers">
|
||||
<MudChip Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
Href="@($"/teams/edit?id={team.Id}")"
|
||||
Variant="@AppIcons.TeamChipVariant()"
|
||||
Href="@($"/teams/edit?id={team.Id}&returnUrl=/students/teams")"
|
||||
Style="cursor: pointer;">
|
||||
@team
|
||||
@if (isCaptain && team.Event.EventFormat != EventFormat.Individual)
|
||||
@@ -98,7 +108,7 @@
|
||||
</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</MudStack>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
@@ -172,14 +182,38 @@
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
// Load all students with their teams
|
||||
// Load teams with their students separately to avoid circular reference
|
||||
var teamsWithStudents = await Context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Students)
|
||||
.Include(t => t.Event)
|
||||
.Include(t => t.Captain)
|
||||
.ToListAsync();
|
||||
|
||||
// Create a dictionary for quick lookup of team students
|
||||
var teamStudentsDict = teamsWithStudents.ToDictionary(t => t.Id, t => t.Students.ToList());
|
||||
|
||||
// Load all students with their teams (without loading team students to avoid circular reference)
|
||||
var students = await Context.Students
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Teams)
|
||||
.ThenInclude(t => t.Event)
|
||||
.Include(s => s.Teams)
|
||||
.ThenInclude(t => t.Captain)
|
||||
.ToListAsync();
|
||||
|
||||
// Populate Students for each team from the separately loaded teams
|
||||
foreach (var student in students)
|
||||
{
|
||||
foreach (var team in student.Teams)
|
||||
{
|
||||
if (teamStudentsDict.TryGetValue(team.Id, out var teamStudents))
|
||||
{
|
||||
team.Students = teamStudents;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to only students with teams
|
||||
var studentTeams = students
|
||||
.Where(s => s.Teams.Any(t => t?.Event != null && (!_showRegionalOnly || t.Event.RegionalEvent)))
|
||||
@@ -270,7 +304,7 @@
|
||||
Student = student,
|
||||
Events = student.Teams?.Where(t => t?.Event != null)
|
||||
.Select(t => t.Event)
|
||||
.ToList() ?? new List<EventDefinition>()
|
||||
.ToList() ?? []
|
||||
};
|
||||
|
||||
return ValidationService.ValidateStudentStatistics(stats, ValidationContext.StudentRegistration);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/teams/assignment"
|
||||
@page "/teams/assignment"
|
||||
@attribute [Authorize]
|
||||
@using Core.Calculation
|
||||
@using Core.Validation
|
||||
@@ -15,9 +15,12 @@
|
||||
Title="Assignment"
|
||||
Description="Optimized team assignments based on the student event rankings">
|
||||
<ActionButtons>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined">
|
||||
Edit Student Event Rankings
|
||||
</MudButton>
|
||||
<MudTooltip Text="Edit Student Event Rankings">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="students/event-ranking" Variant="Variant.Outlined">
|
||||
Edit Student Event Rankings
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
<PageNoteButton PageIdentifier="Team Assignment" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -153,82 +156,87 @@
|
||||
.Concat(context.Events)
|
||||
.Distinct();
|
||||
}
|
||||
@foreach (var e in
|
||||
allStudentEvents
|
||||
.OrderBy(e =>
|
||||
context.Student.EventRankings
|
||||
.Find(ser => ser.EventDefinition == e)?.Rank ?? 10))
|
||||
{
|
||||
var eventRank = context.Student.EventRankings.Find(er => er.EventDefinition == e)?.Rank;
|
||||
var isAssigned = context.Events.Contains(e);
|
||||
|
||||
var color = AppIcons.RankedEventColor(eventRank ?? 0);
|
||||
var style = string.Empty;
|
||||
|
||||
if (isAssigned)
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
@foreach (var e in
|
||||
allStudentEvents
|
||||
.OrderBy(e =>
|
||||
context.Student.EventRankings
|
||||
.Find(ser => ser.EventDefinition == e)?.Rank ?? 10))
|
||||
{
|
||||
style += "border-color:black; border-width:thin;";
|
||||
if (eventRank.HasValue)
|
||||
{
|
||||
style += $"background:{color};";
|
||||
if (eventRank == 1)
|
||||
style += $"color:black";
|
||||
}
|
||||
else
|
||||
style += $"background:{Colors.Gray.Lighten3};";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (eventRank.HasValue)
|
||||
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
|
||||
}
|
||||
var eventRank = context.Student.EventRankings.Find(er => er.EventDefinition == e)?.Rank;
|
||||
var isAssigned = context.Events.Contains(e);
|
||||
|
||||
<MudPaper Class="d-inline-flex align-center pa-2 mx-3 my-1 border-solid" Style="@(style)">
|
||||
@e.ShortName
|
||||
@AppIcons.EventAttributes(e)
|
||||
@{
|
||||
var isIncluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Include) == null;
|
||||
var isExcluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Exclude) == null;
|
||||
}
|
||||
@if (isIncluded)
|
||||
var color = AppIcons.RankedEventColor(eventRank ?? 0);
|
||||
var style = "border-style: solid;";
|
||||
|
||||
if (isAssigned)
|
||||
{
|
||||
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
style += "border-color:black; border-width:thin;";
|
||||
if (eventRank.HasValue)
|
||||
{
|
||||
style += $"background:{color};";
|
||||
if (eventRank == 1)
|
||||
style += $"color:black";
|
||||
}
|
||||
else
|
||||
style += $"background:{Colors.Gray.Lighten3};";
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
if (eventRank.HasValue)
|
||||
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
|
||||
}
|
||||
|
||||
@if (isExcluded)
|
||||
{
|
||||
<MudTooltip Text="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
var isIncluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Include) == null;
|
||||
var isExcluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Exclude) == null;
|
||||
|
||||
<InteractiveChip WrapperClass="my-1" Style="@style" Variant="@AppIcons.EventChipVariant()">
|
||||
<ChildContent>
|
||||
@e.ShortName
|
||||
@AppIcons.EventAttributes(e)
|
||||
</ChildContent>
|
||||
<ControlContent>
|
||||
@if (isIncluded)
|
||||
{
|
||||
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (isExcluded)
|
||||
{
|
||||
<MudTooltip Text="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</ControlContent>
|
||||
</InteractiveChip>
|
||||
}
|
||||
</MudStack>
|
||||
<MudDivider Style="border-width:3px" />
|
||||
</td></MudTr>
|
||||
</ChildRowContent>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
@using WebApp.Models
|
||||
@using Core.Utility
|
||||
@using WebApp.Components.Features.Teams.Components
|
||||
|
||||
@if (Title != null)
|
||||
{
|
||||
<MudText Typo="Typo.h4">@Title</MudText>
|
||||
}
|
||||
|
||||
<MudToggleGroup T="Team"
|
||||
SelectionMode="SelectionMode.MultiSelection"
|
||||
Values="@SelectedTeams"
|
||||
ValuesChanged="@OnSelectedTeamsChanged"
|
||||
Vertical="true"
|
||||
CheckMark>
|
||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.Event.Name))
|
||||
{
|
||||
<MudToggleItem Value="@team" Style="font-size: .75rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px; width: 100%;">
|
||||
<MudTooltip Text="@TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain,
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
})">
|
||||
<span class="ellipsis" style="flex: 1; min-width: 0;">@team.ToString()</span>
|
||||
</MudTooltip>
|
||||
@if (IsSelected(team))
|
||||
{
|
||||
var isExtended = IsExtended(team);
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudTooltip Text="@(isExtended ? "Remove from extended teams" : "Extend to 2 time slots")">
|
||||
<MudIconButton Icon="@(isExtended ? Icons.Material.Filled.AddCircle : Icons.Material.Filled.Add)"
|
||||
Size="Size.Small"
|
||||
Color="@(isExtended ? Color.Primary : Color.Default)"
|
||||
Variant="@(isExtended ? Variant.Filled : Variant.Text)"
|
||||
OnClick="@(() => ToggleExtended(team))"
|
||||
Style="margin-left: 4px; padding: 2px;" />
|
||||
</MudTooltip>
|
||||
<TeamMeetingHistoryBadge TeamId="@team.Id" TeamName="@team.ToString()" />
|
||||
</MudStack>
|
||||
}
|
||||
@if (ShowEventAttributes)
|
||||
{
|
||||
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
|
||||
}
|
||||
</div>
|
||||
</MudToggleItem>
|
||||
}
|
||||
</MudToggleGroup>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public IEnumerable<Team> Teams { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public IEnumerable<Team> SelectedTeams { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<IEnumerable<Team>> SelectedTeamsChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IEnumerable<Team> ExtendedTeams { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<IEnumerable<Team>> ExtendedTeamsChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool ShowEventAttributes { get; set; } = true;
|
||||
|
||||
private bool IsSelected(Team team)
|
||||
{
|
||||
var selectedTeamIds = SelectedTeams.Select(t => t.Id).ToHashSet();
|
||||
return selectedTeamIds.Contains(team.Id);
|
||||
}
|
||||
|
||||
private bool IsExtended(Team team)
|
||||
{
|
||||
var extendedTeamIds = ExtendedTeams.Select(t => t.Id).ToHashSet();
|
||||
return extendedTeamIds.Contains(team.Id);
|
||||
}
|
||||
|
||||
private async Task OnSelectedTeamsChanged(IEnumerable<Team> value)
|
||||
{
|
||||
SelectedTeams = value;
|
||||
await SelectedTeamsChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task ToggleExtended(Team team)
|
||||
{
|
||||
var extendedTeamIds = ExtendedTeams.Select(t => t.Id).ToHashSet();
|
||||
IEnumerable<Team> newExtendedTeams;
|
||||
|
||||
if (extendedTeamIds.Contains(team.Id))
|
||||
{
|
||||
// Remove from extended teams
|
||||
newExtendedTeams = ExtendedTeams.Where(t => t.Id != team.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add to extended teams
|
||||
newExtendedTeams = ExtendedTeams.Concat([team]);
|
||||
}
|
||||
|
||||
ExtendedTeams = newExtendedTeams;
|
||||
await ExtendedTeamsChanged.InvokeAsync(newExtendedTeams);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@using WebApp.Models
|
||||
<div class="d-flex flex-wrap" style="gap: 0.25rem;">
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
@foreach (var student in
|
||||
Team.Students
|
||||
.OrderBy(e =>
|
||||
@@ -17,7 +17,7 @@
|
||||
<MudTooltip Text="@rankLabel">
|
||||
<MudChip T="string"
|
||||
Size="Size.Medium"
|
||||
Variant="Variant.Outlined"
|
||||
Variant="@AppIcons.StudentChipVariant()"
|
||||
Class="mx-1 my-1">
|
||||
@if (eventRank.HasValue)
|
||||
{
|
||||
@@ -31,7 +31,7 @@
|
||||
</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@using WebApp.Models
|
||||
@using Core.Utility
|
||||
|
||||
@if (Title != null)
|
||||
{
|
||||
@@ -14,9 +15,15 @@
|
||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.Event.Name))
|
||||
{
|
||||
<MudToggleItem Value="@team" Style="font-size: .75rem;">
|
||||
<MudTooltip Text="@team.StudentsFirstNames">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<MudText Class="ellipsis">@team.ToString()</MudText>
|
||||
<MudTooltip Text="@TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain,
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
})">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px; width: 100%;">
|
||||
<span class="ellipsis" style="flex: 1; min-width: 0;">@team.ToString()</span>
|
||||
@if (ShowEventAttributes)
|
||||
{
|
||||
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using WebApp.Components.Features.Teams.Components
|
||||
@using MudBlazor
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IDialogService DialogService
|
||||
|
||||
@if (Team is null)
|
||||
{
|
||||
@@ -19,12 +21,21 @@
|
||||
BackButtonUrl="@(ReturnUrl ?? "/teams")">
|
||||
<ActionButtons>
|
||||
<div class="no-print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")"
|
||||
Variant="Variant.Outlined">Edit</MudButton>
|
||||
<MudTooltip Text="Print">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
</MudTooltip>
|
||||
<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>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
@@ -82,4 +93,26 @@
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Students)
|
||||
.FirstOrDefaultAsync(m => m.Id == Id);
|
||||
_students = await Context.Students.ToListAsync();
|
||||
_students = await Context.Students
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
_selectedStudents = Team?.Students.ToList();
|
||||
|
||||
if (Team is null)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Utility
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
|
||||
@@ -35,7 +36,12 @@ else
|
||||
@if (team.Event.EventFormat == EventFormat.Team)
|
||||
{
|
||||
<span style="font-weight: normal; font-size: 0.9em;">
|
||||
(Team: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName)))
|
||||
(Team: @TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending
|
||||
}))
|
||||
</span>
|
||||
}
|
||||
</MudText>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Utility
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
|
||||
@@ -57,7 +58,12 @@ else
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudText Class="d-flex py-1" Typo="Typo.h6">
|
||||
Team Members: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName))
|
||||
Team Members: @TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending
|
||||
})
|
||||
</MudText>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using WebApp.Models
|
||||
@page "/teams"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@inject AppDbContext Context
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageHeader Title="Teams">
|
||||
<ActionButtons>
|
||||
<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/printout" Variant="Variant.Outlined">Printout</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
||||
<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 Text="Create New">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="teams/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Printout">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/printout" Variant="Variant.Outlined">Printout</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Handout">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="State schedule handout (print)">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.CalendarMonth" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">State schedule</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
|
||||
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
|
||||
Color="Color.Primary"
|
||||
OnClick="ToggleRegionalFilter">
|
||||
@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
<PageNoteButton PageIdentifier="Teams" />
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -36,15 +49,16 @@
|
||||
<TemplateColumn Title="Event" Sortable="true" SortBy="@(t => t.Event.Name)">
|
||||
<CellTemplate>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
<MudLink Href="@($"/teams/details?id={context.Item.Id}")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
<MudLink Href="@($"/teams/details?id={context.Item.Id}&returnUrl=/teams")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
@context.Item.ToString()
|
||||
</MudLink>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<TeamMeetingHistoryBadge TeamId="@context.Item.Id" TeamName="@context.Item.ToString()" />
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||
TooltipText="Edit"
|
||||
Href="@($"/teams/edit?id={context.Item.Id}")" />
|
||||
Href="@($"/teams/edit?id={context.Item.Id}&returnUrl=/teams")" />
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
||||
TooltipText="Delete"
|
||||
HoverColor="Color.Error"
|
||||
@@ -74,31 +88,60 @@
|
||||
MudDataGrid<Team> _dataGrid = null!;
|
||||
private bool _isLoading = true;
|
||||
private bool _showRegionalOnly = false;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
private async Task ToggleRegionalFilter()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
_showRegionalOnly = !_showRegionalOnly;
|
||||
if (_dataGrid != null)
|
||||
if (_dataGrid != null && !_isDisposed)
|
||||
{
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GridData<Team>> ServerReload(GridState<Team> state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return new GridData<Team> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
IQueryable<Team> query
|
||||
= Context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Captain)
|
||||
.Include(e => e.Students)
|
||||
.ThenInclude(e => e.EventRankings);
|
||||
|
||||
@@ -113,7 +156,7 @@
|
||||
query = query.Where(state.FilterDefinitions);
|
||||
|
||||
// Load all data first
|
||||
var allTeams = await query.ToArrayAsync();
|
||||
var allTeams = await query.ToArrayAsync(cancellationToken);
|
||||
|
||||
// Always sort by EventFormat FIRST to separate group/individual teams
|
||||
// Sort in memory to ensure this ordering is maintained regardless of user sorts
|
||||
@@ -173,45 +216,117 @@
|
||||
Items = pagedData
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, return empty result
|
||||
return new GridData<Team> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, return empty result
|
||||
return new GridData<Team> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTeam(Team team)
|
||||
{
|
||||
//_isRowBlocked = true;
|
||||
if (_isDisposed) return;
|
||||
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete team",
|
||||
(MarkupString)$"Are you sure want to delete <b>{team}</b>? This cannot be undone.",
|
||||
yesText: "Yes",
|
||||
noText: "Cancel");
|
||||
|
||||
if (result == true)
|
||||
try
|
||||
{
|
||||
// If deleting a numbered team (1 or 2), clear the identifier of the remaining team
|
||||
if (team.Identifier == "1" || team.Identifier == "2")
|
||||
{
|
||||
var remainingTeam = await Context.Teams
|
||||
.Include(t => t.Event)
|
||||
.FirstOrDefaultAsync(t => t.Event.Id == team.Event.Id && t.Id != team.Id);
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
if (remainingTeam != null)
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete team",
|
||||
(MarkupString)$"Are you sure want to delete <b>{team}</b>? This cannot be undone.",
|
||||
yesText: "Yes",
|
||||
noText: "Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
// Load the team fresh from database with tracking to avoid tracking conflicts
|
||||
var teamToDelete = await Context.Teams
|
||||
.Include(t => t.Event)
|
||||
.Include(t => t.Students)
|
||||
.FirstOrDefaultAsync(t => t.Id == team.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (teamToDelete == null)
|
||||
{
|
||||
remainingTeam.Identifier = null;
|
||||
Context.Teams.Update(remainingTeam);
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add("Team not found or already deleted", Severity.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If deleting a numbered team (1 or 2), clear the identifier of the remaining team
|
||||
if (teamToDelete.Identifier == "1" || teamToDelete.Identifier == "2")
|
||||
{
|
||||
var remainingTeam = await Context.Teams
|
||||
.Include(t => t.Event)
|
||||
.FirstOrDefaultAsync(t => t.Event.Id == teamToDelete.Event.Id && t.Id != teamToDelete.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (remainingTeam != null)
|
||||
{
|
||||
remainingTeam.Identifier = null;
|
||||
Context.Teams.Update(remainingTeam);
|
||||
}
|
||||
}
|
||||
|
||||
Context.Teams.Remove(teamToDelete);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Team {teamToDelete} deleted", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
Context.Teams.Remove(team!);
|
||||
await Context.SaveChangesAsync();
|
||||
Snackbar.Add($"Delete event: Delete of Team {team}", Severity.Info);
|
||||
if (!_isDisposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting team: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@page "/teams/printout"
|
||||
@page "/teams/printout"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Utility
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
|
||||
@@ -40,9 +41,8 @@ else
|
||||
@{
|
||||
var students
|
||||
= context.Students
|
||||
.OrderByDescending(s => s == context.Captain)
|
||||
.ThenBy(s => s.EventRankings.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue)
|
||||
.ThenByDescending(e => e.Grade)
|
||||
.OrderByDescending(s => context.Captain != null && context.Captain.Equals(s))
|
||||
.ThenByDescending(e => e.Grade + e.TsaYear)
|
||||
.ThenBy(e => e.FirstName)
|
||||
.ToArray();
|
||||
}
|
||||
@@ -56,7 +56,13 @@ else
|
||||
.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue;
|
||||
|
||||
<MudTd Class="@(EventRankClass(rank))">
|
||||
@student.Name @if(context?.Captain == student) {<span> (Cpt)</span>}
|
||||
@TeamStudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
context,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain
|
||||
})
|
||||
</MudTd>
|
||||
}
|
||||
else
|
||||
@@ -103,7 +109,7 @@ else
|
||||
<RowTemplate>
|
||||
|
||||
<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
|
||||
.OrderBy(e =>
|
||||
@@ -162,7 +168,7 @@ else
|
||||
|
||||
<MudTd>@context.Name</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))
|
||||
{
|
||||
<span>No On-Site Activity</span>
|
||||
@@ -191,7 +197,12 @@ else
|
||||
<span>❔</span>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>@string.Join(", ", team.Students.Where(e => e != context).Select(e => e.FirstName))</MudTd>
|
||||
<MudTd>@TeamStudentNameFormatter.FormatStudentList(
|
||||
new Team { Students = team.Students.Where(e => e != context).ToList(), Event = team.Event },
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
})</MudTd>
|
||||
<MudTd></MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@@ -218,8 +229,10 @@ else
|
||||
{
|
||||
_teams
|
||||
= await Context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Students)
|
||||
.Include(e => e.Captain)
|
||||
.OrderByEventFormatFirst()
|
||||
.ThenBy(e => e.Event.Name)
|
||||
.ThenBy(e => e.Identifier ?? "")
|
||||
@@ -228,8 +241,11 @@ else
|
||||
_maxTeamSize = _teams.Max(t => t.Students.Count);
|
||||
_students =
|
||||
await Context.Students
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Captain)
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Event)
|
||||
.Include(e => e.EventRankings)
|
||||
.ThenInclude(e => e.EventDefinition)
|
||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
||||
|
||||
@@ -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]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using Core.Entities
|
||||
@using WebApp.Services
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Heron.MudCalendar
|
||||
@inject IConfiguration Configuration
|
||||
@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>
|
||||
|
||||
@@ -26,6 +36,18 @@
|
||||
</div>
|
||||
</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)
|
||||
{
|
||||
<!-- Getting Started: No students yet -->
|
||||
@@ -105,14 +127,77 @@ else
|
||||
</MudPaper>
|
||||
<MudGrid>
|
||||
<DashboardCard Icon="@AppIcons.Scheduler"
|
||||
Title="Meeting Schedule"
|
||||
Caption="Optimize meeting times"
|
||||
NavigateUrl="/meeting-schedule"/>
|
||||
Title="Meeting Schedule Planner"
|
||||
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"
|
||||
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>
|
||||
|
||||
<MudPaper Elevation="0" Class="my-4">
|
||||
@@ -132,21 +217,6 @@ else
|
||||
NavigateUrl="/students/teams"/>
|
||||
</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">
|
||||
<MudText Typo="Typo.h4">Chapter Data</MudText>
|
||||
</MudPaper>
|
||||
@@ -171,6 +241,21 @@ else
|
||||
Caption="@($"{_teamEventsCount} Team | {_individualEventsCount} Individual")"
|
||||
NavigateUrl="/events" />
|
||||
</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 {
|
||||
@@ -182,13 +267,59 @@ else
|
||||
private int _teamCount;
|
||||
private int _individualTeamsCount;
|
||||
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 _hasTeams => _teamCount > 0;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
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()
|
||||
@@ -225,4 +356,95 @@ else
|
||||
_individualTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Individual);
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
@using Core.Entities
|
||||
@using Core.Services
|
||||
@using WebApp.Services
|
||||
@using PSC.Blazor.Components.MarkdownEditor
|
||||
@using MudBlazor
|
||||
@inject INotesService NotesService
|
||||
@inject INoteNamingService NoteNamingService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject MarkdownTablePasteService MarkdownTablePasteService
|
||||
|
||||
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField @bind-Value="_note.Title"
|
||||
Label="Title"
|
||||
Variant="Variant.Outlined"
|
||||
Required="true"
|
||||
RequiredError="Title is required"
|
||||
MaxLength="200"
|
||||
ReadOnly="@IsPageNote" />
|
||||
|
||||
<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>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public Note Note { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public bool IsEdit { get; set; }
|
||||
|
||||
private Note _note = null!;
|
||||
private bool _pasteMarkdownInitialized = false;
|
||||
|
||||
private bool IsPageNote => Note?.Title != null && NoteNamingService.IsPageNote(Note.Title);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_note = new Note
|
||||
{
|
||||
Id = Note.Id,
|
||||
Title = Note.Title ?? "",
|
||||
Content = Note.Content ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_note.Title))
|
||||
{
|
||||
Snackbar.Add("Title is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (IsEdit)
|
||||
{
|
||||
await NotesService.UpdateNoteAsync(_note);
|
||||
Snackbar.Add("Note updated successfully", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await NotesService.CreateNoteAsync(_note);
|
||||
Snackbar.Add("Note created successfully", Severity.Success);
|
||||
}
|
||||
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error saving note: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
MudDialog.Close(DialogResult.Cancel());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
@using Core.Entities
|
||||
@using WebApp.Services
|
||||
@using MudBlazor
|
||||
@inject INotesService NotesService
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(NoteId)">
|
||||
<DialogContent>
|
||||
@if (_isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||
}
|
||||
else if (!_history.Any())
|
||||
{
|
||||
<MudText Typo="Typo.body1" Align="Align.Center" Class="my-4">
|
||||
No history available for this note.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@_history" Hover="true" Striped="true" Dense="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Date/Time</MudTh>
|
||||
<MudTh>User</MudTh>
|
||||
<MudTh>Change Type</MudTh>
|
||||
<MudTh>Title</MudTh>
|
||||
<MudTh>Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Date/Time">
|
||||
@context.ModifiedAt.ToString("g")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="User">
|
||||
@(context.ModifiedBy ?? "Unknown")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Change Type">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Color="@GetChangeTypeColor(context.ChangeType)">
|
||||
@context.ChangeType
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Title">
|
||||
@context.Title
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Visibility"
|
||||
OnClick="() => ViewVersion(context)"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small">
|
||||
View
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Close">Close</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public int NoteId { get; set; }
|
||||
|
||||
private List<NoteHistory> _history = [];
|
||||
private bool _isLoading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadHistory();
|
||||
}
|
||||
|
||||
private async Task LoadHistory()
|
||||
{
|
||||
try
|
||||
{
|
||||
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Error handling - could show snackbar if we had access
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetChangeTypeColor(string changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
"Created" => Color.Success,
|
||||
"Updated" => Color.Info,
|
||||
"Deleted" => Color.Error,
|
||||
"Soft Deleted" => Color.Error,
|
||||
"Restored" => Color.Success,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ViewVersion(NoteHistory history)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["History"] = history
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<NoteVersionViewDialog>("View Version", parameters, options);
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
MudDialog.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@using Core.Entities
|
||||
@using WebApp.Services
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_history.NoteId)">
|
||||
<DialogContent>
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.h6">@_history.Title</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Modified: @_history.ModifiedAt.ToString("g") by @(_history.ModifiedBy ?? "Unknown")
|
||||
</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetChangeTypeColor(_history.ChangeType)">
|
||||
@_history.ChangeType
|
||||
</MudChip>
|
||||
<MudDivider />
|
||||
@if (!string.IsNullOrWhiteSpace(_history.Content))
|
||||
{
|
||||
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
|
||||
<div class="markdown-content">
|
||||
@((MarkupString)MarkdownHelper.ToHtml(_history.Content))
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
|
||||
No content in this version.
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Close">Close</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public NoteHistory History { get; set; } = null!;
|
||||
|
||||
private NoteHistory _history = null!;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_history = History;
|
||||
}
|
||||
|
||||
private Color GetChangeTypeColor(string changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
"Created" => Color.Success,
|
||||
"Updated" => Color.Info,
|
||||
"Deleted" => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private void Close()
|
||||
{
|
||||
MudDialog.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
@page "/notes"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Core.Entities
|
||||
@using Core.Services
|
||||
@using WebApp.Services
|
||||
@using WebApp.Components.Shared.Components
|
||||
@inject INotesService NotesService
|
||||
@inject INoteNamingService NoteNamingService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageHeader Title="Notes">
|
||||
<ActionButtons>
|
||||
<MudTooltip Text="@(_showRemoved ? "Show Active" : "Show Removed")">
|
||||
<MudButton StartIcon="@(_showRemoved ? Icons.Material.Filled.List : Icons.Material.Filled.DeleteOutline)"
|
||||
OnClick="ToggleRemovedFilter"
|
||||
Variant="Variant.Outlined"
|
||||
Color="@(_showRemoved ? Color.Warning : Color.Default)">
|
||||
@(_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>
|
||||
</PageHeader>
|
||||
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||
}
|
||||
else if (!_notes.Any())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Align="Align.Center" Class="my-8">
|
||||
No notes yet. Create your first note to get started!
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ClientSidePagination T="Note" Items="@_notes" DefaultPageSize="@DefaultPageSize">
|
||||
<ChildContent Context="paginatedNotes">
|
||||
<MudExpansionPanels MultiExpansion="true">
|
||||
@foreach (var note in paginatedNotes)
|
||||
{
|
||||
var noteId = note.Id;
|
||||
var initialExpanded = _selectedNoteId.HasValue && noteId == _selectedNoteId.Value;
|
||||
if (!_expandedNotes.ContainsKey(noteId))
|
||||
{
|
||||
_expandedNotes[noteId] = initialExpanded;
|
||||
}
|
||||
var isExpanded = GetNoteExpanded(noteId);
|
||||
<MudExpansionPanel @key="@($"note-{noteId}")"
|
||||
Icon="@Icons.Material.Filled.Note"
|
||||
IsExpanded="@isExpanded"
|
||||
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
|
||||
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
|
||||
<TitleContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="flex-grow-1">
|
||||
<MudText Typo="Typo.body1" Class="flex-grow-1" Style="min-width: 0;">
|
||||
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;">
|
||||
@GetNoteHeaderText(note, isExpanded)
|
||||
</span>
|
||||
</MudText>
|
||||
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
|
||||
{
|
||||
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
|
||||
OnClick="() => TogglePin(note)"
|
||||
OnClick:StopPropagation="true"
|
||||
Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
|
||||
Disabled="@(IsPinDisabled(note))"
|
||||
Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
|
||||
Class="flex-shrink-0" />
|
||||
}
|
||||
</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>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
</ChildContent>
|
||||
</ClientSidePagination>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
// Constants
|
||||
private const int MaxPreviewLength = 60;
|
||||
private const int MaxPinnedNotes = 3;
|
||||
private const int DefaultPageSize = 25;
|
||||
|
||||
private List<Note> _notes = [];
|
||||
private bool _isLoading = true;
|
||||
private bool _showRemoved = false;
|
||||
private int _pinnedCount = 0;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
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()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_selectedNoteId = Id;
|
||||
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()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
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)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error loading notes: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["Note"] = new Note { Title = "", Content = "" },
|
||||
["IsEdit"] = false
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (!result.Canceled && !_isDisposed)
|
||||
{
|
||||
await LoadNotes();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog(Note note)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["Note"] = note,
|
||||
["IsEdit"] = true
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true,
|
||||
CloseButton = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (!result.Canceled && !_isDisposed)
|
||||
{
|
||||
await LoadNotes();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenHistoryDialog(int noteId)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["NoteId"] = noteId
|
||||
};
|
||||
|
||||
await DialogService.ShowAsync<NoteHistoryDialog>("Note History", parameters, GetDefaultDialogOptions());
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"Delete Note",
|
||||
(MarkupString)$"Are you sure you want to delete <b>{note.Title}</b>? You can restore it later from the 'Show Removed' view.",
|
||||
yesText: "Yes",
|
||||
noText: "Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
await NotesService.DeleteNoteAsync(note.Id);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Note '{note.Title}' deleted", Severity.Info);
|
||||
await LoadNotes();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting note: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||
<NotAuthorized>
|
||||
@{
|
||||
navigationManager.NavigateTo("/login", true);
|
||||
var currentUri = navigationManager.Uri;
|
||||
var baseUri = navigationManager.BaseUri;
|
||||
var relativePath = currentUri.Replace(baseUri, "").TrimStart('/');
|
||||
var returnUrl = string.IsNullOrEmpty(relativePath) ? "/" : "/" + relativePath;
|
||||
var encodedReturnUrl = Uri.EscapeDataString(returnUrl);
|
||||
navigationManager.NavigateTo($"/login?returnUrl={encodedReturnUrl}", true);
|
||||
}
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@namespace WebApp.Components.Shared.Components
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
|
||||
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center"
|
||||
Style="position: relative; display: inline-flex;"
|
||||
Class="@WrapperClass"
|
||||
@onmouseenter="@(() => _isHovered = true)"
|
||||
@onmouseleave="@(() => _isHovered = false)"
|
||||
@ontouchstart="@HandleTouchStart">
|
||||
<MudChip T="string"
|
||||
Size="@Size"
|
||||
Color="@Color"
|
||||
Variant="@Variant"
|
||||
Style="@Style"
|
||||
Class="@Class">
|
||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||
@ChildContent
|
||||
@if (ControlContent != null)
|
||||
{
|
||||
@* 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>
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public required RenderFragment ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ControlContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Size Size { get; set; } = Size.Small;
|
||||
|
||||
[Parameter]
|
||||
public Color Color { get; set; } = Color.Default;
|
||||
|
||||
[Parameter]
|
||||
public Variant Variant { get; set; } = Variant.Text;
|
||||
|
||||
[Parameter]
|
||||
public string? Style { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? Class { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? WrapperClass { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool AlwaysShowControls { get; set; } = 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@using WebApp.Models
|
||||
@using WebApp.Models
|
||||
@using WebApp.Authentication
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
@@ -18,14 +18,18 @@
|
||||
<MudNavLink Href="/students/teams" Icon="@AppIcons.Registration">Registration</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<MudNavGroup Title="Chapter Data" Icon="@Icons.Material.Filled.Storage" Expanded="false">
|
||||
<MudNavLink Href="/students" Icon="@Icons.Material.Filled.People">Students</MudNavLink>
|
||||
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
|
||||
</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">
|
||||
<MudNavLink Href="/students" Icon="@Icons.Material.Filled.People">Students</MudNavLink>
|
||||
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
|
||||
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
|
||||
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<AuthorizeView Roles="Administrator">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@@ -28,3 +28,5 @@
|
||||
@using Core.Models
|
||||
@using Data
|
||||
@using VisNetwork.Blazor
|
||||
@using PSC.Blazor.Components.MarkdownEditor
|
||||
@using WebApp.Services
|
||||
@@ -9,10 +9,12 @@ namespace WebApp;
|
||||
public sealed class LocalStorageService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly ILogger<LocalStorageService> _logger;
|
||||
|
||||
public LocalStorageService(IJSRuntime jsRuntime)
|
||||
public LocalStorageService(IJSRuntime jsRuntime, ILogger<LocalStorageService> logger)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,7 +49,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to save boolean to localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to save boolean to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +85,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to save integer to localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to save integer to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +102,14 @@ public sealed class LocalStorageService
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
var array = JsonSerializer.Deserialize<int[]>(json);
|
||||
return array ?? Array.Empty<int>();
|
||||
return array ?? [];
|
||||
}
|
||||
return Array.Empty<int>();
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to load integer array from localStorage [{key}]: {ex.Message}");
|
||||
return Array.Empty<int>();
|
||||
_logger.LogWarning(ex, "Failed to load integer array from localStorage [{Key}]", key);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +127,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to save integer array to localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to save integer array to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +143,50 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to remove from localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to remove from localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a JSON-serialized object from localStorage.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <param name="key">The storage key.</param>
|
||||
/// <returns>The deserialized object or default value if not found.</returns>
|
||||
public async Task<T?> GetJsonAsync<T>(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load JSON from localStorage [{Key}]", key);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a JSON-serialized object in localStorage.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to serialize.</typeparam>
|
||||
/// <param name="key">The storage key.</param>
|
||||
/// <param name="value">The object to store.</param>
|
||||
public async Task SetJsonAsync<T>(string key, T value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save JSON to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +201,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to clear localStorage: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to clear localStorage");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,15 +33,14 @@ namespace WebApp.Logging
|
||||
LogEventLevel.Information,
|
||||
null, // No exception at Info level
|
||||
messageTemplate,
|
||||
new[]
|
||||
{
|
||||
[
|
||||
new LogEventProperty("OriginalMessage",
|
||||
new ScalarValue(logEvent.MessageTemplate.Render(logEvent.Properties))),
|
||||
new LogEventProperty("SourceContext",
|
||||
logEvent.Properties.GetValueOrDefault("SourceContext") ?? new ScalarValue("Unknown")),
|
||||
new LogEventProperty("RequestPath",
|
||||
logEvent.Properties.GetValueOrDefault("RequestPath") ?? new ScalarValue("Unknown"))
|
||||
});
|
||||
]);
|
||||
|
||||
_wrappedSink.Emit(rewrittenEvent);
|
||||
}
|
||||
|
||||
@@ -154,5 +154,20 @@ namespace WebApp.Models
|
||||
};
|
||||
return $"{number}<sup>{suffix}</sup>";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the standard chip variant for student chips (Outlined).
|
||||
/// </summary>
|
||||
public static Variant StudentChipVariant() => Variant.Outlined;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the standard chip variant for event chips (Filled).
|
||||
/// </summary>
|
||||
public static Variant EventChipVariant() => Variant.Filled;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the standard chip variant for team chips (Filled).
|
||||
/// </summary>
|
||||
public static Variant TeamChipVariant() => Variant.Filled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class CalendarEventItem : CalendarItem
|
||||
EventDefinition = occurrence.EventDefinition;
|
||||
// Set base class properties that the calendar component uses
|
||||
StudentFirstNames = studentFirstNames?.ToList() ?? [];
|
||||
Text = occurrence.EventDefinition?.ShortName;
|
||||
Text = occurrence.EventDefinition?.ShortName ?? string.Empty;
|
||||
Start = occurrence.StartTime;
|
||||
End = occurrence.EndTime ?? occurrence.StartTime.AddHours(1);
|
||||
|
||||
|
||||
@@ -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>
|
||||
public string CompetitionYear { get; set; } = "2026";
|
||||
|
||||
/// <summary>
|
||||
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
|
||||
/// </summary>
|
||||
public string StateAbbrev { get; set; } = "ST";
|
||||
|
||||
/// <summary>
|
||||
/// School level for the chapter (null = import both MS and HS events)
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,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"
|
||||
];
|
||||
}
|
||||
+20
-9
@@ -11,6 +11,7 @@ using WebApp;
|
||||
using WebApp.Authentication;
|
||||
using WebApp.Components;
|
||||
using WebApp.Logging;
|
||||
using WebApp.Models;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -101,17 +102,16 @@ builder.Host.UseSerilog((context, configuration) =>
|
||||
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
|
||||
});
|
||||
|
||||
// Configure authentication secrets for production (Docker, etc.)
|
||||
// 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())
|
||||
{
|
||||
// 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_");
|
||||
}
|
||||
|
||||
@@ -193,6 +193,17 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
|
||||
});
|
||||
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
||||
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
|
||||
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)
|
||||
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,7 +38,10 @@ public class EventDefinitionService
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all existing careers from database (case-insensitive lookup)
|
||||
// Get existing careers with tracking so we use the same instances the context
|
||||
// may already be tracking (e.g. from the event's RelatedCareers). Using
|
||||
// AsNoTracking() would create duplicate instances and cause "another instance
|
||||
// with the same key value is already being tracked".
|
||||
var existingCareers = await _context.Careers
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
.ToListAsync();
|
||||
|
||||
@@ -39,13 +39,14 @@ public class EventOccurrenceService : IEventOccurrenceService
|
||||
}
|
||||
|
||||
var teams = await _context.Teams
|
||||
.Include(t => t.Event)
|
||||
.Include(t => t.Students)
|
||||
.Include(t => t.Captain)
|
||||
.Where(t => ids.Contains(t.Event.Id))
|
||||
.Where(t => t.Event != null && ids.Contains(t.Event.Id))
|
||||
.ToListAsync();
|
||||
|
||||
return teams
|
||||
.GroupBy(t => t.Event.Id)
|
||||
.GroupBy(t => t.Event!.Id)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user