Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4aaf91df | ||
|
|
5fdda08627 | ||
|
|
68311f4012 | ||
|
|
27f08b3718 | ||
|
|
34658e9697 | ||
|
|
8c4c21f204 | ||
|
|
52bf303537 | ||
|
|
c505bf42dd | ||
|
|
aba8ea3ae3 | ||
|
|
e2a5767b04 | ||
|
|
1601610226 | ||
|
|
f8c22690d4 | ||
|
|
6cd4418142 | ||
|
|
6acbc4e852 | ||
|
|
5a1b3fad2e | ||
|
|
8af86e22d9 | ||
|
|
e53403c934 | ||
|
|
5e6d61d400 |
+3
-1
@@ -23,7 +23,8 @@ _ReSharper*/
|
|||||||
|
|
||||||
DataBackup/
|
DataBackup/
|
||||||
*.db
|
*.db
|
||||||
/.claude/*
|
/.*/*
|
||||||
|
.*rules
|
||||||
|
|
||||||
# Production secrets and configuration
|
# Production secrets and configuration
|
||||||
auth-secrets.json
|
auth-secrets.json
|
||||||
@@ -34,3 +35,4 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Runtime data directory
|
# Runtime data directory
|
||||||
/WebApp/Data/*
|
/WebApp/Data/*
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ namespace Core.Calculation
|
|||||||
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
||||||
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
||||||
{
|
{
|
||||||
var buffer = new List<ILiteral>();
|
List<ILiteral> buffer = [];
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
buffer.Clear();
|
buffer.Clear();
|
||||||
@@ -273,7 +273,7 @@ namespace Core.Calculation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
var prohibitVar = new List<IntVar>(1);
|
List<IntVar> prohibitVar = [];
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
@@ -328,7 +328,7 @@ namespace Core.Calculation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
var studentCapacity = new List<IntVar>();
|
List<IntVar> studentCapacity = [];
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
@@ -418,7 +418,7 @@ namespace Core.Calculation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
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)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
eventCapacity.Add(x[eventIndex, s]);
|
eventCapacity.Add(x[eventIndex, s]);
|
||||||
@@ -455,7 +455,7 @@ namespace Core.Calculation
|
|||||||
model.AddAssumption(x[e, s]);
|
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))
|
foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude))
|
||||||
{
|
{
|
||||||
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
||||||
|
|||||||
@@ -82,10 +82,11 @@ public class TeamScheduler
|
|||||||
var model = new CpModel();
|
var model = new CpModel();
|
||||||
|
|
||||||
// Build membership matrix: m[i,t] = 1 if student i is on team t, else 0
|
// 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];
|
var m = new int[_students.Length,_teams.Length];
|
||||||
foreach (var i in _students)
|
foreach (var i in _students)
|
||||||
foreach (var t in _teams)
|
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:
|
// Decision variables:
|
||||||
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
|
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ public class TeamSchedulerSolution(
|
|||||||
public Team[] StudentUnassignedTeams(Student student)
|
public Team[] StudentUnassignedTeams(Student student)
|
||||||
{
|
{
|
||||||
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
|
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];
|
var timeSlots = new IList<Team>[_timeSlotCount];
|
||||||
for (var i = 0; i < _timeSlotCount; i++)
|
for (var i = 0; i < _timeSlotCount; i++)
|
||||||
timeSlots[i] = new List<Team>();
|
timeSlots[i] = [];
|
||||||
|
|
||||||
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
<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.EntityFrameworkCore" Version="9.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||||
|
|||||||
@@ -43,13 +43,10 @@ public class CareerField
|
|||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
Description = description;
|
Description = description;
|
||||||
DirectCareerMatches = directCareerMatches ?? Array.Empty<string>();
|
DirectCareerMatches = directCareerMatches ?? [];
|
||||||
PatternKeywords = patternKeywords ?? Array.Empty<string>();
|
PatternKeywords = patternKeywords ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Name;
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,21 +37,6 @@ public class EventDefinition
|
|||||||
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
||||||
|
|
||||||
public bool OnSiteActivity { get; set; }
|
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)]
|
[StringLength(1024)]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
@@ -79,15 +64,12 @@ public class EventDefinition
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int? LevelOfEffort { 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]
|
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||||
public string? RelatedCareersText { get; set; }
|
public string? RelatedCareersText { get; set; }
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Name;
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
||||||
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
|
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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; }
|
||||||
|
|
||||||
|
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!;
|
||||||
|
}
|
||||||
@@ -63,17 +63,4 @@ public class Team
|
|||||||
{
|
{
|
||||||
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string StudentsFirstNames
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
return
|
|
||||||
string.Join(", ",
|
|
||||||
Students.Select(e =>
|
|
||||||
e.FirstName
|
|
||||||
+ (Captain != null && (Captain.Equals(e)) ? "(Cpt)" : ""))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -14,11 +14,11 @@ public static class EventOccurrenceGrammar
|
|||||||
/// Array of all month names in order (January through December).
|
/// Array of all month names in order (January through December).
|
||||||
/// This is the single source of truth for month names used throughout the parser.
|
/// This is the single source of truth for month names used throughout the parser.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly string[] MonthNames = new[]
|
public static readonly string[] MonthNames =
|
||||||
{
|
[
|
||||||
"January", "February", "March", "April", "May", "June",
|
"January", "February", "March", "April", "May", "June",
|
||||||
"July", "August", "September", "October", "November", "December"
|
"July", "August", "September", "October", "November", "December"
|
||||||
};
|
];
|
||||||
|
|
||||||
// Build month parsers dynamically from MonthNames array
|
// Build month parsers dynamically from MonthNames array
|
||||||
private static readonly Parser<string>[] MonthParsers = MonthNames
|
private static readonly Parser<string>[] MonthParsers = MonthNames
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class StudentEventRankingParser : CsvParserBase
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
|
||||||
var competitiveEvents = new List<EventDefinition>(6);
|
var competitiveEvents = new List<EventDefinition>();
|
||||||
|
|
||||||
for (var i = 1; i <= 6; i++)
|
for (var i = 1; i <= 6; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class StudentParser : CsvParserBase
|
|||||||
|
|
||||||
public Student[] Parse()
|
public Student[] Parse()
|
||||||
{
|
{
|
||||||
var s = new List<Student>();
|
var students = new List<Student>();
|
||||||
|
|
||||||
CsvReader.Read();
|
CsvReader.Read();
|
||||||
CsvReader.ReadHeader();
|
CsvReader.ReadHeader();
|
||||||
@@ -44,9 +44,9 @@ public class StudentParser : CsvParserBase
|
|||||||
RegionalId = regionalId,
|
RegionalId = regionalId,
|
||||||
NationalId = nationalId
|
NationalId = nationalId
|
||||||
};
|
};
|
||||||
s.Add(student);
|
students.Add(student);
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.ToArray();
|
return students.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ public static class CareerFieldDefinitions
|
|||||||
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
|
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
|
||||||
{
|
{
|
||||||
if (careers == null)
|
if (careers == null)
|
||||||
return Array.Empty<CareerField>();
|
return [];
|
||||||
|
|
||||||
var careerNames = careers
|
var careerNames = careers
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
@@ -31,7 +31,7 @@ public static class CareerFieldDefinitions
|
|||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (!careerNames.Any())
|
if (!careerNames.Any())
|
||||||
return Array.Empty<CareerField>();
|
return [];
|
||||||
|
|
||||||
var matchingFields = new HashSet<CareerField>();
|
var matchingFields = new HashSet<CareerField>();
|
||||||
var allFields = GetAllCareerFields();
|
var allFields = GetAllCareerFields();
|
||||||
@@ -74,15 +74,15 @@ public static class CareerFieldDefinitions
|
|||||||
|
|
||||||
private static IReadOnlyList<CareerField> CreateAllCareerFields()
|
private static IReadOnlyList<CareerField> CreateAllCareerFields()
|
||||||
{
|
{
|
||||||
return new List<CareerField>
|
return
|
||||||
{
|
[
|
||||||
// 1. Aerospace & Automotive Engineering
|
// 1. Aerospace & Automotive Engineering
|
||||||
new CareerField(
|
new CareerField(
|
||||||
1,
|
1,
|
||||||
"Aerospace & Automotive Engineering",
|
"Aerospace & Automotive Engineering",
|
||||||
"Careers focused on designing and engineering aircraft, spacecraft, and vehicles for transportation.",
|
"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" },
|
[ "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" ],
|
||||||
new[] { "aeronautical", "aircraft", "automobile", "automotive", "race car" }
|
[ "aeronautical", "aircraft", "automobile", "automotive", "race car" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 2. Mechanical & Robotics Engineering
|
// 2. Mechanical & Robotics Engineering
|
||||||
@@ -90,8 +90,8 @@ public static class CareerFieldDefinitions
|
|||||||
2,
|
2,
|
||||||
"Mechanical & Robotics Engineering",
|
"Mechanical & Robotics Engineering",
|
||||||
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
|
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
|
||||||
new[] { "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" },
|
[ "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" ],
|
||||||
new[] { "mechanical", "robotics", "machine" }
|
[ "mechanical", "robotics", "machine" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3. Electrical & Electronics Engineering
|
// 3. Electrical & Electronics Engineering
|
||||||
@@ -99,8 +99,8 @@ public static class CareerFieldDefinitions
|
|||||||
3,
|
3,
|
||||||
"Electrical & Electronics Engineering",
|
"Electrical & Electronics Engineering",
|
||||||
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
|
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
|
||||||
new[] { "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" },
|
[ "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" ],
|
||||||
new[] { "electrical", "electronic", "electrician" }
|
[ "electrical", "electronic", "electrician" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 4. Civil & Structural Engineering
|
// 4. Civil & Structural Engineering
|
||||||
@@ -108,8 +108,8 @@ public static class CareerFieldDefinitions
|
|||||||
4,
|
4,
|
||||||
"Civil & Structural Engineering",
|
"Civil & Structural Engineering",
|
||||||
"Engineering fields focused on infrastructure, buildings, bridges, and construction project management.",
|
"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" },
|
[ "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" ],
|
||||||
new[] { "civil", "construction", "structural", "contractor" }
|
[ "civil", "construction", "structural", "contractor" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 5. Environmental & Energy Engineering
|
// 5. Environmental & Energy Engineering
|
||||||
@@ -117,8 +117,8 @@ public static class CareerFieldDefinitions
|
|||||||
5,
|
5,
|
||||||
"Environmental & Energy Engineering",
|
"Environmental & Energy Engineering",
|
||||||
"Engineering careers focused on sustainable energy solutions, environmental protection, and chemical processes.",
|
"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" },
|
[ "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" ],
|
||||||
new[] { "chemical", "energy", "environmental", "solar" }
|
[ "chemical", "energy", "environmental", "solar" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 6. General Engineering & Quality
|
// 6. General Engineering & Quality
|
||||||
@@ -126,8 +126,8 @@ public static class CareerFieldDefinitions
|
|||||||
6,
|
6,
|
||||||
"General Engineering & Quality",
|
"General Engineering & Quality",
|
||||||
"Broad engineering roles including management, quality assurance, and standards compliance across various industries.",
|
"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" },
|
[ "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" ],
|
||||||
new[] { "engineer", "quality", "standards" }
|
[ "engineer", "quality", "standards" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 7. Architecture & Urban Planning
|
// 7. Architecture & Urban Planning
|
||||||
@@ -135,8 +135,8 @@ public static class CareerFieldDefinitions
|
|||||||
7,
|
7,
|
||||||
"Architecture & Urban Planning",
|
"Architecture & Urban Planning",
|
||||||
"Design and planning careers focused on buildings, spaces, and community development.",
|
"Design and planning careers focused on buildings, spaces, and community development.",
|
||||||
new[] { "Architect", "Community planner", "Interior designer", "Urban and regional planner" },
|
[ "Architect", "Community planner", "Interior designer", "Urban and regional planner" ],
|
||||||
new[] { "architect", "planner", "interior design", "urban" }
|
[ "architect", "planner", "interior design", "urban" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 8. Software Development
|
// 8. Software Development
|
||||||
@@ -144,8 +144,8 @@ public static class CareerFieldDefinitions
|
|||||||
8,
|
8,
|
||||||
"Software Development",
|
"Software Development",
|
||||||
"Careers in creating, designing, and developing computer software applications and systems.",
|
"Careers in creating, designing, and developing computer software applications and systems.",
|
||||||
new[] { "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" },
|
[ "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" ],
|
||||||
new[] { "programming", "programmer", "software", "developer" }
|
[ "programming", "programmer", "software", "developer" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 9. IT & Networking
|
// 9. IT & Networking
|
||||||
@@ -153,8 +153,8 @@ public static class CareerFieldDefinitions
|
|||||||
9,
|
9,
|
||||||
"IT & Networking",
|
"IT & Networking",
|
||||||
"Information technology careers involving computer systems, networks, technical support, and telecommunications.",
|
"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" },
|
[ "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" ],
|
||||||
new[] { "network", "computer", "technical support", "telecommunications", "IT" }
|
[ "network", "computer", "technical support", "telecommunications", "IT" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 10. Cybersecurity & Digital Forensics
|
// 10. Cybersecurity & Digital Forensics
|
||||||
@@ -162,8 +162,8 @@ public static class CareerFieldDefinitions
|
|||||||
10,
|
10,
|
||||||
"Cybersecurity & Digital Forensics",
|
"Cybersecurity & Digital Forensics",
|
||||||
"Security-focused careers protecting digital systems, investigating cybercrimes, and ensuring information security.",
|
"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" },
|
[ "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" }
|
[ "cyber", "security", "forensics", "cryptography", "vulnerability" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 11. Data Science & Analytics
|
// 11. Data Science & Analytics
|
||||||
@@ -171,8 +171,8 @@ public static class CareerFieldDefinitions
|
|||||||
11,
|
11,
|
||||||
"Data Science & Analytics",
|
"Data Science & Analytics",
|
||||||
"Careers analyzing data, applying mathematical and statistical methods to solve problems and make decisions.",
|
"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" },
|
[ "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" ],
|
||||||
new[] { "data", "analyst", "actuary", "economist", "mathematician", "research" }
|
[ "data", "analyst", "actuary", "economist", "mathematician", "research" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 12. CAD, CNC & Manufacturing
|
// 12. CAD, CNC & Manufacturing
|
||||||
@@ -180,8 +180,8 @@ public static class CareerFieldDefinitions
|
|||||||
12,
|
12,
|
||||||
"CAD, CNC & Manufacturing",
|
"CAD, CNC & Manufacturing",
|
||||||
"Careers in computer-aided design, manufacturing processes, and production planning.",
|
"Careers in computer-aided design, manufacturing processes, and production planning.",
|
||||||
new[] { "CAD professional", "CNC programmer", "Manufacturing", "Production planner" },
|
[ "CAD professional", "CNC programmer", "Manufacturing", "Production planner" ],
|
||||||
new[] { "CAD", "CNC", "manufacturing", "production" }
|
[ "CAD", "CNC", "manufacturing", "production" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 13. Industrial & Product Design
|
// 13. Industrial & Product Design
|
||||||
@@ -189,8 +189,8 @@ public static class CareerFieldDefinitions
|
|||||||
13,
|
13,
|
||||||
"Industrial & Product Design",
|
"Industrial & Product Design",
|
||||||
"Design careers creating products, commercial goods, and industrial solutions with focus on form and function.",
|
"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" },
|
[ "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" ],
|
||||||
new[] { "designer", "design", "industrial", "product", "appraiser" }
|
[ "designer", "design", "industrial", "product", "appraiser" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 14. Visual Arts & Animation
|
// 14. Visual Arts & Animation
|
||||||
@@ -198,8 +198,8 @@ public static class CareerFieldDefinitions
|
|||||||
14,
|
14,
|
||||||
"Visual Arts & Animation",
|
"Visual Arts & Animation",
|
||||||
"Creative careers in visual design, illustration, animation, and digital art creation.",
|
"Creative careers in visual design, illustration, animation, and digital art creation.",
|
||||||
new[] { "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" },
|
[ "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" ],
|
||||||
new[] { "animator", "artist", "graphic", "illustrator", "multimedia" }
|
[ "animator", "artist", "graphic", "illustrator", "multimedia" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 15. Game Design & Interactive Media
|
// 15. Game Design & Interactive Media
|
||||||
@@ -207,8 +207,8 @@ public static class CareerFieldDefinitions
|
|||||||
15,
|
15,
|
||||||
"Game Design & Interactive Media",
|
"Game Design & Interactive Media",
|
||||||
"Careers in video game design, development, testing, and professional gaming.",
|
"Careers in video game design, development, testing, and professional gaming.",
|
||||||
new[] { "Game designer", "Game Play Tester", "Professional Gamer" },
|
[ "Game designer", "Game Play Tester", "Professional Gamer" ],
|
||||||
new[] { "game", "gamer", "gaming" }
|
[ "game", "gamer", "gaming" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 16. Audio & Music Production
|
// 16. Audio & Music Production
|
||||||
@@ -216,8 +216,8 @@ public static class CareerFieldDefinitions
|
|||||||
16,
|
16,
|
||||||
"Audio & Music Production",
|
"Audio & Music Production",
|
||||||
"Careers in audio engineering, music composition, sound design, and broadcast technology.",
|
"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" },
|
[ "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" ],
|
||||||
new[] { "audio", "music", "broadcast", "sound" }
|
[ "audio", "music", "broadcast", "sound" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 17. Video & Film Production
|
// 17. Video & Film Production
|
||||||
@@ -225,8 +225,8 @@ public static class CareerFieldDefinitions
|
|||||||
17,
|
17,
|
||||||
"Video & Film Production",
|
"Video & Film Production",
|
||||||
"Careers in video production, filmmaking, directing, and television broadcasting.",
|
"Careers in video production, filmmaking, directing, and television broadcasting.",
|
||||||
new[] { "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" },
|
[ "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" ],
|
||||||
new[] { "video", "film", "director", "television", "broadcast", "videographer" }
|
[ "video", "film", "director", "television", "broadcast", "videographer" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 18. Web & Digital Communications
|
// 18. Web & Digital Communications
|
||||||
@@ -234,8 +234,8 @@ public static class CareerFieldDefinitions
|
|||||||
18,
|
18,
|
||||||
"Web & Digital Communications",
|
"Web & Digital Communications",
|
||||||
"Careers in web design, digital communication, and instructional technology.",
|
"Careers in web design, digital communication, and instructional technology.",
|
||||||
new[] { "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" },
|
[ "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" ],
|
||||||
new[] { "web", "website", "digital", "communications", "webmaster" }
|
[ "web", "website", "digital", "communications", "webmaster" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 19. Writing & Publishing
|
// 19. Writing & Publishing
|
||||||
@@ -243,8 +243,8 @@ public static class CareerFieldDefinitions
|
|||||||
19,
|
19,
|
||||||
"Writing & Publishing",
|
"Writing & Publishing",
|
||||||
"Careers in writing, editing, publishing, and content creation across various media formats.",
|
"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" },
|
[ "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" ],
|
||||||
new[] { "writing", "writer", "editor", "publisher", "copy" }
|
[ "writing", "writer", "editor", "publisher", "copy" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 20. Journalism & Public Relations
|
// 20. Journalism & Public Relations
|
||||||
@@ -252,8 +252,8 @@ public static class CareerFieldDefinitions
|
|||||||
20,
|
20,
|
||||||
"Journalism & Public Relations",
|
"Journalism & Public Relations",
|
||||||
"Careers in news reporting, photojournalism, public relations, and communications management.",
|
"Careers in news reporting, photojournalism, public relations, and communications management.",
|
||||||
new[] { "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" },
|
[ "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" ],
|
||||||
new[] { "journalism", "reporter", "photojournalist", "communications", "speaker" }
|
[ "journalism", "reporter", "photojournalist", "communications", "speaker" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 21. Forensics & Criminal Investigation
|
// 21. Forensics & Criminal Investigation
|
||||||
@@ -261,8 +261,8 @@ public static class CareerFieldDefinitions
|
|||||||
21,
|
21,
|
||||||
"Forensics & Criminal Investigation",
|
"Forensics & Criminal Investigation",
|
||||||
"Careers in criminal investigation, forensic science, and analyzing evidence for legal proceedings.",
|
"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" },
|
[ "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" ],
|
||||||
new[] { "forensic", "detective", "investigator", "crime" }
|
[ "forensic", "detective", "investigator", "crime" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 22. Healthcare & Medical Technology
|
// 22. Healthcare & Medical Technology
|
||||||
@@ -270,8 +270,8 @@ public static class CareerFieldDefinitions
|
|||||||
22,
|
22,
|
||||||
"Healthcare & Medical Technology",
|
"Healthcare & Medical Technology",
|
||||||
"Medical and healthcare careers providing patient care, medical technology, and health services.",
|
"Medical and healthcare careers providing patient care, medical technology, and health services.",
|
||||||
new[] { "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" },
|
[ "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" ],
|
||||||
new[] { "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" }
|
[ "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 23. Science & Research
|
// 23. Science & Research
|
||||||
@@ -279,8 +279,8 @@ public static class CareerFieldDefinitions
|
|||||||
23,
|
23,
|
||||||
"Science & Research",
|
"Science & Research",
|
||||||
"Scientific research careers across biology, physics, meteorology, and other scientific disciplines.",
|
"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" },
|
[ "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" }
|
[ "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 24. Education & Training
|
// 24. Education & Training
|
||||||
@@ -288,8 +288,8 @@ public static class CareerFieldDefinitions
|
|||||||
24,
|
24,
|
||||||
"Education & Training",
|
"Education & Training",
|
||||||
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
|
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
|
||||||
new[] { "Educator", "Teacher/trainer", "Technology education instructor" },
|
[ "Educator", "Teacher/trainer", "Technology education instructor" ],
|
||||||
new[] { "educator", "teacher", "trainer", "education", "instructor" }
|
[ "educator", "teacher", "trainer", "education", "instructor" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 25. Business, Legal & Government
|
// 25. Business, Legal & Government
|
||||||
@@ -297,10 +297,10 @@ public static class CareerFieldDefinitions
|
|||||||
25,
|
25,
|
||||||
"Business, Legal & Government",
|
"Business, Legal & Government",
|
||||||
"Careers in business management, legal services, government, politics, and public policy.",
|
"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" },
|
[ "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" }
|
[ "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,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 Core.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ namespace Data
|
|||||||
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
||||||
public DbSet<EventOccurrence> EventOccurrences { get; set; }
|
public DbSet<EventOccurrence> EventOccurrences { get; set; }
|
||||||
public DbSet<Career> Careers { get; set; }
|
public DbSet<Career> Careers { get; set; }
|
||||||
|
public DbSet<Note> Notes { get; set; }
|
||||||
|
public DbSet<NoteHistory> NoteHistories { get; set; }
|
||||||
|
|
||||||
public AppDbContext()
|
public AppDbContext()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
// 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,481 @@
|
|||||||
|
// <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("20260115185640_AddNotesAndNoteHistory")]
|
||||||
|
partial class AddNotesAndNoteHistory
|
||||||
|
{
|
||||||
|
/// <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<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("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,92 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNotesAndNoteHistory : 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)
|
||||||
|
},
|
||||||
|
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_Title",
|
||||||
|
table: "Notes",
|
||||||
|
column: "Title");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NoteHistories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,6 +177,83 @@ namespace Data.Migrations
|
|||||||
b.ToTable("EventOccurrences");
|
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<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("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 =>
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -323,6 +400,17 @@ namespace Data.Migrations
|
|||||||
b.Navigation("EventDefinition");
|
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 =>
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
@@ -375,6 +463,11 @@ namespace Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.Student", b =>
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("EventRankings");
|
b.Navigation("EventRankings");
|
||||||
|
|||||||
+5
-5
@@ -10,11 +10,11 @@
|
|||||||
<None Remove="Parsers\TestInput\2024 TN TSA State Competition Event Times.txt" />
|
<None Remove="Parsers\TestInput\2024 TN TSA State Competition Event Times.txt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||||
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
|
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||||
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
|
<PackageReference Include="NUnit.Analyzers" Version="4.11.2" />
|
||||||
<PackageReference Include="coverlet.collector" Version="3.2.0" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Core\Core.csproj" />
|
<ProjectReference Include="..\Core\Core.csproj" />
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ namespace WebApp.Authentication
|
|||||||
public async Task<IActionResult> CookieLogin(
|
public async Task<IActionResult> CookieLogin(
|
||||||
[FromForm] string email,
|
[FromForm] string email,
|
||||||
[FromForm] string password,
|
[FromForm] string password,
|
||||||
[FromForm] bool rememberMe = false)
|
[FromForm] bool rememberMe = false,
|
||||||
|
[FromForm] string? returnUrl = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -42,7 +43,10 @@ namespace WebApp.Authentication
|
|||||||
ipAddress, remaining);
|
ipAddress, remaining);
|
||||||
|
|
||||||
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
|
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
|
// Validate credentials
|
||||||
@@ -57,7 +61,10 @@ namespace WebApp.Authentication
|
|||||||
"Failed login attempt for {Email} from {IpAddress}",
|
"Failed login attempt for {Email} from {IpAddress}",
|
||||||
email, 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
|
// Success - clear rate limit tracking
|
||||||
@@ -89,13 +96,22 @@ namespace WebApp.Authentication
|
|||||||
"Successful login for {Email} ({Role}) from {IpAddress}",
|
"Successful login for {Email} ({Role}) from {IpAddress}",
|
||||||
result.Email, result.Role, 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("/");
|
return Redirect("/");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during login process");
|
_logger.LogError(ex, "Error during login process");
|
||||||
TempData["LoginError"] = "An error occurred. Please try again.";
|
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">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
<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="@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="stylesheet" href="app.css" />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<HeadOutlet />
|
<HeadOutlet />
|
||||||
@@ -17,9 +19,12 @@
|
|||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
</AppErrorBoundary>
|
</AppErrorBoundary>
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.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="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>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<input type="hidden" id="emailInput" name="email" value="" />
|
<input type="hidden" id="emailInput" name="email" value="" />
|
||||||
<input type="hidden" id="passwordInput" name="password" value="" />
|
<input type="hidden" id="passwordInput" name="password" value="" />
|
||||||
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
|
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
|
||||||
|
<input type="hidden" id="returnUrlInput" name="returnUrl" value="@_returnUrl" />
|
||||||
|
|
||||||
<MudTextField @bind-Value="_loginModel.Email"
|
<MudTextField @bind-Value="_loginModel.Email"
|
||||||
Label="Email"
|
Label="Email"
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
private MudInputType _passwordInput = MudInputType.Password;
|
private MudInputType _passwordInput = MudInputType.Password;
|
||||||
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
|
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
|
||||||
private string _antiforgeryToken = string.Empty;
|
private string _antiforgeryToken = string.Empty;
|
||||||
|
private string? _returnUrl;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
@@ -86,13 +88,17 @@
|
|||||||
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
|
_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 uri = new Uri(Navigation.Uri);
|
||||||
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
||||||
if (queryParams.TryGetValue("error", out var errorValue))
|
if (queryParams.TryGetValue("error", out var errorValue))
|
||||||
{
|
{
|
||||||
_errorMessage = errorValue.ToString();
|
_errorMessage = errorValue.ToString();
|
||||||
}
|
}
|
||||||
|
if (queryParams.TryGetValue("returnUrl", out var returnUrlValue))
|
||||||
|
{
|
||||||
|
_returnUrl = returnUrlValue.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LoginModel
|
private class LoginModel
|
||||||
@@ -141,10 +147,12 @@
|
|||||||
private async Task HandleFormSubmit()
|
private async Task HandleFormSubmit()
|
||||||
{
|
{
|
||||||
// Update hidden inputs with current model values, then submit the form
|
// 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", $@"
|
await JS.InvokeVoidAsync("eval", $@"
|
||||||
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
||||||
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
||||||
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
||||||
|
document.getElementById('returnUrlInput').value = {returnUrlValue};
|
||||||
document.getElementById('loginForm').submit();
|
document.getElementById('loginForm').submit();
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1 @@
|
|||||||
@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>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudStack Spacing="3">
|
|
||||||
@if (EventDefinition != 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>
|
|
||||||
}
|
|
||||||
</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))
|
|
||||||
{
|
|
||||||
<div>
|
|
||||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Time</MudText>
|
|
||||||
<MudText Typo="Typo.body1">@EventOccurrence.Time</MudText>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (StudentFirstNames != null && 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>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
</DialogContent>
|
|
||||||
</MudDialog>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public EventOccurrence? EventOccurrence { get; set; }
|
|
||||||
[Parameter] public EventDefinition? EventDefinition { get; set; }
|
|
||||||
[Parameter] public List<string>? StudentFirstNames { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@using Heron.MudCalendar
|
@using Heron.MudCalendar
|
||||||
@using Microsoft.Extensions.Logging
|
@using Microsoft.Extensions.Logging
|
||||||
@using WebApp.Authentication
|
@using WebApp.Authentication
|
||||||
|
@using Core.Utility
|
||||||
@inject IEventOccurrenceService EventOccurrenceService
|
@inject IEventOccurrenceService EventOccurrenceService
|
||||||
@inject ILogger<Index> Logger
|
@inject ILogger<Index> Logger
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
// Load teams for all event definitions
|
// Load teams for all event definitions
|
||||||
var teamsByEventId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
|
var teamsByEventId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
|
||||||
|
|
||||||
var items = new List<CalendarEventItem>();
|
List<CalendarEventItem> items = [];
|
||||||
foreach (var occ in eventOccurrences)
|
foreach (var occ in eventOccurrences)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -110,9 +111,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get student first names for this event definition
|
// Get student first names for this event definition
|
||||||
var studentFirstNames = occ.EventDefinition != null
|
var studentFirstNames = occ.EventDefinition != null && teamsByEventId.TryGetValue(occ.EventDefinition.Id, out var teams)
|
||||||
? StudentFirstNames(occ.EventDefinition, teamsByEventId)
|
? TeamStudentNameFormatter.FormatStudentListForEvent(
|
||||||
: new List<string>();
|
occ.EventDefinition,
|
||||||
|
teams,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Star,
|
||||||
|
Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
var calendarItem = new CalendarEventItem(occ, studentFirstNames);
|
var calendarItem = new CalendarEventItem(occ, studentFirstNames);
|
||||||
items.Add(calendarItem);
|
items.Add(calendarItem);
|
||||||
@@ -135,7 +143,7 @@
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error loading calendar events");
|
Logger.LogError(ex, "Error loading calendar events");
|
||||||
_calendarItems = new List<CalendarEventItem>();
|
_calendarItems = [];
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -143,34 +151,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> StudentFirstNames(EventDefinition ed, Dictionary<int, List<Team>> teamsByEventId)
|
|
||||||
{
|
|
||||||
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()
|
private DateTime GetNextDateWithEvents()
|
||||||
{
|
{
|
||||||
@@ -220,7 +200,7 @@
|
|||||||
|
|
||||||
private string GetEventTooltip(CalendarEventItem item)
|
private string GetEventTooltip(CalendarEventItem item)
|
||||||
{
|
{
|
||||||
var parts = new List<string>();
|
List<string> parts = [];
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(item.EventDefinition?.Name))
|
if (!string.IsNullOrEmpty(item.EventDefinition?.Name))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ else
|
|||||||
Click on a node to see details. Use mouse to zoom and pan the graph.
|
Click on a node to see details. Use mouse to zoom and pan the graph.
|
||||||
</MudText>
|
</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)
|
@if (_selectedNodeInfo != null)
|
||||||
{
|
{
|
||||||
<MudPaper Elevation="1" Class="pa-4 mb-4" Style="background-color: #f5f5f5;">
|
<MudPaper Elevation="1" Class="pa-4 mb-4" Style="background-color: #f5f5f5;">
|
||||||
@@ -61,9 +65,6 @@ else
|
|||||||
</MudPaper>
|
</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>
|
</MudPaper>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ else
|
|||||||
{
|
{
|
||||||
if (!_fieldIdToCareers.ContainsKey(field.Id))
|
if (!_fieldIdToCareers.ContainsKey(field.Id))
|
||||||
{
|
{
|
||||||
_fieldIdToCareers[field.Id] = new List<string>();
|
_fieldIdToCareers[field.Id] = [];
|
||||||
}
|
}
|
||||||
// Add unique career names for this field
|
// Add unique career names for this field
|
||||||
foreach (var career in evt.RelatedCareers)
|
foreach (var career in evt.RelatedCareers)
|
||||||
@@ -152,8 +153,8 @@ else
|
|||||||
|
|
||||||
private NetworkData GenerateNetworkData(List<EventDefinition> events)
|
private NetworkData GenerateNetworkData(List<EventDefinition> events)
|
||||||
{
|
{
|
||||||
var nodes = new List<Node>();
|
List<Node> nodes = [];
|
||||||
var edges = new List<Edge>();
|
List<Edge> edges = [];
|
||||||
|
|
||||||
// Dictionary to track node IDs (to avoid duplicates)
|
// Dictionary to track node IDs (to avoid duplicates)
|
||||||
var eventNodeIds = new Dictionary<int, string>();
|
var eventNodeIds = new Dictionary<int, string>();
|
||||||
@@ -283,7 +284,7 @@ else
|
|||||||
Title = field.Name,
|
Title = field.Name,
|
||||||
Description = field.Description,
|
Description = field.Description,
|
||||||
IsCareerField = true,
|
IsCareerField = true,
|
||||||
Careers = careers?.ToList() ?? new List<string>()
|
Careers = careers?.ToList() ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{
|
{
|
||||||
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
|
<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 loeIcon = AppIcons.LevelOfEffortIcon(EventDefinition.LevelOfEffort);
|
||||||
var loeColor = AppIcons.IconColors.GetValueOrDefault(loeIcon, "inherit");
|
var loeColor = AppIcons.IconColors.GetValueOrDefault(loeIcon, "inherit");
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||||
OnClick="PrintPage"
|
OnClick="PrintPage"
|
||||||
Variant="Variant.Outlined">Print</MudButton>
|
Variant="Variant.Outlined">Print</MudButton>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}")" Variant="Variant.Outlined">Edit</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||||
</div>
|
</div>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/events"
|
@page "/events"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@implements IAsyncDisposable
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
<PropertyColumn Property="@(e => e.Name)" Title="Event Name" Sortable="true">
|
<PropertyColumn Property="@(e => e.Name)" Title="Event Name" Sortable="true">
|
||||||
<CellTemplate>
|
<CellTemplate>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||||
<MudLink Href="@($"/events/details?id={context.Item.Id}")"
|
<MudLink Href="@($"/events/details?id={context.Item.Id}&returnUrl=/events")"
|
||||||
Underline="Underline.Hover"
|
Underline="Underline.Hover"
|
||||||
Color="Color.Primary">
|
Color="Color.Primary">
|
||||||
@context.Item.Name
|
@context.Item.Name
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||||
TooltipText="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"
|
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
||||||
TooltipText="Delete"
|
TooltipText="Delete"
|
||||||
HoverColor="Color.Error"
|
HoverColor="Color.Error"
|
||||||
@@ -74,16 +75,32 @@
|
|||||||
@code {
|
@code {
|
||||||
MudDataGrid<EventDefinition> _dataGrid = null!;
|
MudDataGrid<EventDefinition> _dataGrid = null!;
|
||||||
private bool _isLoading = true;
|
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)
|
private async Task<GridData<EventDefinition>> ServerReload(GridState<EventDefinition> state)
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
try
|
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 query = Context.Events
|
||||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
|
.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>
|
return new GridData<EventDefinition>
|
||||||
{
|
{
|
||||||
@@ -91,15 +108,30 @@
|
|||||||
Items = pagedData
|
Items = pagedData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
{
|
{
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeleteEventDefinition(EventDefinition evt)
|
private async Task DeleteEventDefinition(EventDefinition evt)
|
||||||
{
|
{
|
||||||
//_isRowBlocked = true;
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
|
||||||
var result = await DialogService
|
var result = await DialogService
|
||||||
.ShowMessageBox("Delete Event",
|
.ShowMessageBox("Delete Event",
|
||||||
@@ -107,15 +139,66 @@
|
|||||||
yesText:"Yes",
|
yesText:"Yes",
|
||||||
noText:"Cancel");
|
noText:"Cancel");
|
||||||
|
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
if (result == true)
|
if (result == true)
|
||||||
{
|
{
|
||||||
Context.Events.Remove(evt!);
|
// Load the event fresh from database with tracking to avoid tracking conflicts
|
||||||
await Context.SaveChangesAsync();
|
var eventToDelete = await Context.Events
|
||||||
Snackbar.Add($"Delete event: Delete of Event {evt.Name}", Severity.Info);
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
//_isRowBlocked = false;
|
Context.Events.Remove(eventToDelete);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Event {eventToDelete.Name} deleted", Severity.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await _dataGrid.ReloadServerData();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using Core.Utility
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject ClipboardService ClipboardService
|
@inject ClipboardService ClipboardService
|
||||||
@@ -45,7 +46,13 @@
|
|||||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
|
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
||||||
|
Class="ma-3"
|
||||||
|
OnClick="Solve"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Disabled="@_isSolving">
|
||||||
|
Solve
|
||||||
|
</MudButton>
|
||||||
<MudTooltip Text="Copy to Clipboard">
|
<MudTooltip Text="Copy to Clipboard">
|
||||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
@@ -62,11 +69,14 @@
|
|||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" lg="6">
|
<MudItem xs="12" lg="6">
|
||||||
<ScheduledTeamsList TimeSlotName="@context.Name"
|
<ScheduledTeamsList TimeSlotName="@context.Name"
|
||||||
|
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
|
||||||
Teams="@context.Teams"
|
Teams="@context.Teams"
|
||||||
ScheduledTeams="@_scheduledTeams"
|
ScheduledTeams="@_scheduledTeams"
|
||||||
AbsentStudents="@_absentStudents"
|
AbsentStudents="@_absentStudents"
|
||||||
StudentHasOverlaps="@context.StudentHasOverlaps"
|
StudentHasOverlaps="@context.StudentHasOverlaps"
|
||||||
OnToggleTeam="@ToggleRequiredTeam" />
|
ExcludedStudents="@_excludedStudents"
|
||||||
|
OnToggleTeam="@ToggleRequiredTeam"
|
||||||
|
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" lg="6">
|
<MudItem xs="12" lg="6">
|
||||||
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
||||||
@@ -90,11 +100,13 @@
|
|||||||
Label="Search for absent students"
|
Label="Search for absent students"
|
||||||
ShowFullName="true"/>
|
ShowFullName="true"/>
|
||||||
<MudDivider Class="my-4"/>
|
<MudDivider Class="my-4"/>
|
||||||
<TeamToggleSelector Teams="@_teams"
|
<TeamMeetingToggleSelector Teams="@_teams"
|
||||||
SelectedTeams="_scheduledTeams"
|
SelectedTeams="_scheduledTeams"
|
||||||
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
||||||
|
ExtendedTeams="_extendedTeams"
|
||||||
|
ExtendedTeamsChanged="OnExtendedTeamsChanged"
|
||||||
Title="Scheduled Teams"
|
Title="Scheduled Teams"
|
||||||
ShowEventAttributes="true" />
|
ShowEventAttributes="false" />
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
@@ -111,76 +123,99 @@
|
|||||||
private IEnumerable<Team> _scheduledTeams = [];
|
private IEnumerable<Team> _scheduledTeams = [];
|
||||||
private IEnumerable<Student> _absentStudents = [];
|
private IEnumerable<Student> _absentStudents = [];
|
||||||
private IEnumerable<Team> _possibleAdditions = [];
|
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;
|
_scheduledTeams = teams;
|
||||||
await SaveScheduledTeams();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnAbsentStudentsChanged(IEnumerable<Student> students)
|
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
|
||||||
{
|
{
|
||||||
_absentStudents = students;
|
_absentStudents = students;
|
||||||
await SaveAbsentStudents();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnTimeSlotCountChanged(int timeSlots)
|
private async Task OnTimeSlotCountChanged(int timeSlots)
|
||||||
{
|
{
|
||||||
_parameters.TimeSlots = timeSlots;
|
_parameters.TimeSlots = timeSlots;
|
||||||
await SaveTimeSlotCount();
|
StateHasChanged();
|
||||||
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void AddRegionals()
|
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
_extendedTeams = teams;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRegionals()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams
|
||||||
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
||||||
await SaveScheduledTeams();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void AddHighLevelOfEffort()
|
private void AddHighLevelOfEffort()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams
|
||||||
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
||||||
await SaveScheduledTeams();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void RemoveIndividual()
|
private void RemoveIndividual()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams
|
||||||
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
||||||
await SaveScheduledTeams();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void RemoveLowLevelOfEffort()
|
private void RemoveLowLevelOfEffort()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams
|
||||||
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
||||||
await SaveScheduledTeams();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Invert()
|
private void Invert()
|
||||||
{
|
{
|
||||||
var rt = _scheduledTeams.ToArray();
|
var rt = _scheduledTeams.ToArray();
|
||||||
_scheduledTeams
|
_scheduledTeams
|
||||||
= _teams.Where(t => !rt.Contains(t));
|
= _teams.Where(t => !rt.Contains(t));
|
||||||
await SaveScheduledTeams();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Reset()
|
private void Reset()
|
||||||
{
|
{
|
||||||
_scheduledTeams = [];
|
_scheduledTeams = [];
|
||||||
await SaveScheduledTeams();
|
_extendedTeams = [];
|
||||||
|
_excludedStudents.Clear();
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ToggleRequiredTeam(Team unassignedTeam)
|
private void ToggleRequiredTeam(Team unassignedTeam)
|
||||||
{
|
{
|
||||||
if (_scheduledTeams.Contains(unassignedTeam))
|
// Find the matching team from _teams to ensure reference equality with MudToggleGroup
|
||||||
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam);
|
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
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
@@ -216,6 +251,7 @@
|
|||||||
|
|
||||||
_teams
|
_teams
|
||||||
= await Context.Teams
|
= await Context.Teams
|
||||||
|
.AsNoTracking()
|
||||||
.Include(e => e.Event)
|
.Include(e => e.Event)
|
||||||
.Include(e => e.Students)
|
.Include(e => e.Students)
|
||||||
.OrderBy(e => e.Event.Name)
|
.OrderBy(e => e.Event.Name)
|
||||||
@@ -224,8 +260,11 @@
|
|||||||
|
|
||||||
_students =
|
_students =
|
||||||
await Context.Students
|
await Context.Students
|
||||||
|
.AsNoTracking()
|
||||||
.Include(e => e.Teams)
|
.Include(e => e.Teams)
|
||||||
.ThenInclude(e => e.Captain)
|
.ThenInclude(t => t.Event)
|
||||||
|
.Include(e => e.Teams)
|
||||||
|
.ThenInclude(t => t.Captain)
|
||||||
.Include(e => e.EventRankings)
|
.Include(e => e.EventRankings)
|
||||||
.ThenInclude(e => e.EventDefinition)
|
.ThenInclude(e => e.EventDefinition)
|
||||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
.OrderBy(e => e.FirstName).ToArrayAsync();
|
||||||
@@ -234,6 +273,21 @@
|
|||||||
await LoadScheduledTeams();
|
await LoadScheduledTeams();
|
||||||
await LoadAbsentStudents();
|
await LoadAbsentStudents();
|
||||||
await LoadTimeSlotCount();
|
await LoadTimeSlotCount();
|
||||||
|
await LoadExtendedTeams();
|
||||||
|
await LoadExcludedStudents();
|
||||||
|
|
||||||
|
// Initialize last saved state from loaded values
|
||||||
|
_lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students);
|
||||||
|
if (_lastSavedState == null)
|
||||||
|
{
|
||||||
|
// If no saved state exists, create initial state from current values
|
||||||
|
_lastSavedState = MeetingScheduleState.FromCurrent(
|
||||||
|
_scheduledTeams,
|
||||||
|
_absentStudents,
|
||||||
|
_parameters.TimeSlots,
|
||||||
|
_extendedTeams,
|
||||||
|
_excludedStudents);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveScheduledTeams()
|
private async Task SaveScheduledTeams()
|
||||||
@@ -280,6 +334,119 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SaveExtendedTeams()
|
||||||
|
{
|
||||||
|
var teamIds = _extendedTeams.Select(t => t.Id).ToArray();
|
||||||
|
await LocalStorage.SetIntArrayAsync("MeetingSchedule_ExtendedTeams", teamIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadExtendedTeams()
|
||||||
|
{
|
||||||
|
var teamIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_ExtendedTeams");
|
||||||
|
if (teamIds.Length > 0)
|
||||||
|
{
|
||||||
|
_extendedTeams = _teams.Where(t => teamIds.Contains(t.Id)).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveExcludedStudents()
|
||||||
|
{
|
||||||
|
var exclusions = _excludedStudents.Keys
|
||||||
|
.Where(k => _excludedStudents[k])
|
||||||
|
.Select(k => new ExcludedStudent(k.teamId, k.timeSlotIndex, k.studentId))
|
||||||
|
.ToArray();
|
||||||
|
await LocalStorage.SetJsonAsync("MeetingSchedule_ExcludedStudents", exclusions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadExcludedStudents()
|
||||||
|
{
|
||||||
|
var exclusions = await LocalStorage.GetJsonAsync<ExcludedStudent[]>("MeetingSchedule_ExcludedStudents");
|
||||||
|
if (exclusions != null && exclusions.Length > 0)
|
||||||
|
{
|
||||||
|
_excludedStudents = exclusions.ToDictionary(
|
||||||
|
e => (e.TeamId, e.TimeSlotIndex, e.StudentId),
|
||||||
|
_ => true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetTimeSlotIndex(string timeSlotName)
|
||||||
|
{
|
||||||
|
if (_solution?.TimeSlots == null)
|
||||||
|
return 0; // Default to first slot if solution not available
|
||||||
|
|
||||||
|
for (int i = 0; i < _solution.TimeSlots.Length; i++)
|
||||||
|
{
|
||||||
|
if (_solution.TimeSlots[i].Name == timeSlotName)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return 0; // Default to first slot if not found (shouldn't happen in normal flow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key)
|
||||||
|
{
|
||||||
|
if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded)
|
||||||
|
{
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates teams with excluded students filtered out for overlap calculation.
|
||||||
|
/// </summary>
|
||||||
|
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
|
||||||
|
{
|
||||||
|
return teams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find excluded students for this team in this time slot
|
||||||
|
// More efficient: iterate through team students and check exclusions
|
||||||
|
var includedStudents = team.Students
|
||||||
|
.Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, s.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// If no students are excluded, return original team
|
||||||
|
if (includedStudents.Count == team.Students.Count)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create a temporary team with excluded students removed
|
||||||
|
return new Team
|
||||||
|
{
|
||||||
|
Id = team.Id,
|
||||||
|
Event = team.Event,
|
||||||
|
Students = includedStudents,
|
||||||
|
Captain = team.Captain,
|
||||||
|
Identifier = team.Identifier
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId);
|
||||||
|
|
||||||
|
private bool IsDirty()
|
||||||
|
{
|
||||||
|
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)
|
private async Task<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
|
||||||
{
|
{
|
||||||
_isSolving = true;
|
_isSolving = true;
|
||||||
@@ -306,23 +473,97 @@
|
|||||||
// Update parameters with absent student names
|
// Update parameters with absent student names
|
||||||
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
|
_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
|
||||||
|
// Aggregate exclusions across all time slots (since we don't know assignment yet)
|
||||||
|
var teamsForScheduling = _scheduledTeams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find all students excluded for this team across all time slots
|
||||||
|
var excludedStudentIds = _excludedStudents.Keys
|
||||||
|
.Where(k => k.teamId == team.Id && _excludedStudents[k])
|
||||||
|
.Select(k => k.studentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
if (excludedStudentIds.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
var excludedStudents = team.Students.Where(s => excludedStudentIds.Contains(s.Id)).ToList();
|
||||||
|
if (excludedStudents.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create PartialTeam with excluded students
|
||||||
|
return team.CloneWithOmittedStudents(excludedStudents);
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
|
||||||
_solution = teamScheduler.Solve();
|
_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())
|
||||||
|
{
|
||||||
|
ExtendTeamsInSolution(_solution, _extendedTeams, availableStudents);
|
||||||
|
}
|
||||||
|
|
||||||
// Try recommendation strategies in priority order
|
// Try recommendation strategies in priority order
|
||||||
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
||||||
var strategies = new[]
|
UnassignedScheduleStrategy[] strategies =
|
||||||
{
|
[
|
||||||
UnassignedScheduleStrategy.LevelOfEffort,
|
UnassignedScheduleStrategy.LevelOfEffort,
|
||||||
UnassignedScheduleStrategy.BiggestGroup,
|
UnassignedScheduleStrategy.BiggestGroup,
|
||||||
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
||||||
UnassignedScheduleStrategy.IndividualEvents
|
UnassignedScheduleStrategy.IndividualEvents
|
||||||
};
|
];
|
||||||
|
|
||||||
_possibleAdditions = strategies
|
_possibleAdditions = strategies
|
||||||
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
||||||
.FirstOrDefault(result => result.Any()) ?? [];
|
.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;
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
||||||
|
|
||||||
_isSolving = false;
|
_isSolving = false;
|
||||||
@@ -334,6 +575,78 @@
|
|||||||
_solutionData.ReloadServerData();
|
_solutionData.ReloadServerData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Move team extension logic into Core.Calculation.TeamScheduler to handle extended teams
|
||||||
|
// as part of the constraint programming model rather than post-processing
|
||||||
|
private void ExtendTeamsInSolution(TeamSchedulerSolution solution, IEnumerable<Team> extendedTeams, Student[] allStudents)
|
||||||
|
{
|
||||||
|
if (solution.TimeSlots == null || !solution.TimeSlots.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamsList = extendedTeams.ToList();
|
||||||
|
if (!extendedTeamsList.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamIds = extendedTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
// Find which time slot each extended team is in and extend both forward and backward
|
||||||
|
for (int slotIndex = 0; slotIndex < solution.TimeSlots.Length; slotIndex++)
|
||||||
|
{
|
||||||
|
var currentSlot = solution.TimeSlots[slotIndex];
|
||||||
|
var teamsToExtend = currentSlot.Teams.Where(t => extendedTeamIds.Contains(t.Id)).ToList();
|
||||||
|
|
||||||
|
if (!teamsToExtend.Any())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Extend forward: add to next time slot (if exists)
|
||||||
|
if (slotIndex + 1 < solution.TimeSlots.Length)
|
||||||
|
{
|
||||||
|
var nextSlot = solution.TimeSlots[slotIndex + 1];
|
||||||
|
var nextSlotTeamsList = nextSlot.Teams.ToList();
|
||||||
|
var nextSlotTeamIds = nextSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!nextSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
nextSlotTeamsList.Add(team);
|
||||||
|
nextSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSlot.Teams = nextSlotTeamsList.ToArray();
|
||||||
|
var nextSlotIndex = slotIndex + 1;
|
||||||
|
var nextSlotTeamsForOverlap = GetTeamsWithoutExcludedStudents(nextSlot.Teams, nextSlotIndex);
|
||||||
|
nextSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(nextSlotTeamsForOverlap);
|
||||||
|
// Use teams without excluded students so students excluded from all teams appear as unscheduled
|
||||||
|
nextSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(nextSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend backward: add to previous time slot (if exists)
|
||||||
|
if (slotIndex > 0)
|
||||||
|
{
|
||||||
|
var previousSlot = solution.TimeSlots[slotIndex - 1];
|
||||||
|
var previousSlotTeamsList = previousSlot.Teams.ToList();
|
||||||
|
var previousSlotTeamIds = previousSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!previousSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
previousSlotTeamsList.Add(team);
|
||||||
|
previousSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSlot.Teams = previousSlotTeamsList.ToArray();
|
||||||
|
var previousSlotIndex = slotIndex - 1;
|
||||||
|
var previousSlotTeamsForOverlap = GetTeamsWithoutExcludedStudents(previousSlot.Teams, previousSlotIndex);
|
||||||
|
previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlotTeamsForOverlap);
|
||||||
|
// Use teams without excluded students so students excluded from all teams appear as unscheduled
|
||||||
|
previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async Task CopyToClipboard()
|
async Task CopyToClipboard()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -356,6 +669,7 @@
|
|||||||
|
|
||||||
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
||||||
{
|
{
|
||||||
|
var timeSlotIndex = GetTimeSlotIndex(timeslot.Name);
|
||||||
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
||||||
{
|
{
|
||||||
var teamName = scheduledTeam.ToString();
|
var teamName = scheduledTeam.ToString();
|
||||||
@@ -366,30 +680,73 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var studentsList = FormatStudentList(scheduledTeam, timeslot);
|
var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex);
|
||||||
sb.Append($"{teamName} - {studentsList}");
|
sb.Append($"{teamName} - {studentsList}");
|
||||||
}
|
}
|
||||||
sb.Append(Environment.NewLine);
|
sb.Append(Environment.NewLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot)
|
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot, int timeSlotIndex)
|
||||||
{
|
{
|
||||||
return string.Join(", ",
|
// Filter out excluded students for this team and time slot
|
||||||
team.Students
|
var excludedStudentIds = _excludedStudents.Keys
|
||||||
.OrderBy(e => e == team.Captain)
|
.Where(k => k.teamId == team.Id && k.timeSlotIndex == timeSlotIndex && _excludedStudents[k])
|
||||||
.ThenBy(e => e.FirstName)
|
.Select(k => k.studentId)
|
||||||
.Select(e => FormatStudentName(e, timeslot)));
|
.ToHashSet();
|
||||||
|
|
||||||
|
var includedStudents = team.Students
|
||||||
|
.Where(s => !excludedStudentIds.Contains(s.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Create a temporary team with only included students for formatting
|
||||||
|
var teamForFormatting = new Team
|
||||||
|
{
|
||||||
|
Id = team.Id,
|
||||||
|
Event = team.Event,
|
||||||
|
Students = includedStudents,
|
||||||
|
Captain = team.Captain,
|
||||||
|
Identifier = team.Identifier
|
||||||
|
};
|
||||||
|
|
||||||
|
return TeamStudentNameFormatter.FormatStudentList(
|
||||||
|
teamForFormatting,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
Ordering = TeamStudentNameFormatter.OrderingStyle.CaptainFirst,
|
||||||
|
MarkOverlaps = true,
|
||||||
|
HasOverlaps = timeslot.StudentHasOverlaps,
|
||||||
|
MarkAbsent = true,
|
||||||
|
AbsentStudents = _absentStudents.ToList()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
|
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
|
||||||
{
|
{
|
||||||
var name = student.FirstName;
|
// Find the team this student belongs to for formatting context
|
||||||
if (timeslot.StudentHasOverlaps(student))
|
var team = _teams.FirstOrDefault(t => t.Students.Contains(student));
|
||||||
name += "*";
|
if (team == null)
|
||||||
if (_absentStudents.Contains(student))
|
{
|
||||||
name += " (absent)";
|
// No team context, use StudentNameFormatter directly
|
||||||
return name;
|
return StudentNameFormatter.FormatStudentName(
|
||||||
|
student,
|
||||||
|
new StudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
HasOverlap = timeslot.StudentHasOverlaps(student),
|
||||||
|
IsAbsent = _absentStudents.Contains(student)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return TeamStudentNameFormatter.FormatStudentName(
|
||||||
|
student,
|
||||||
|
team,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
MarkOverlaps = true,
|
||||||
|
HasOverlaps = timeslot.StudentHasOverlaps,
|
||||||
|
MarkAbsent = true,
|
||||||
|
AbsentStudents = _absentStudents.ToList()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AppendUnscheduledStudents(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
private void AppendUnscheduledStudents(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
||||||
@@ -402,9 +759,12 @@
|
|||||||
|
|
||||||
foreach (var student in timeslot.UnscheduledStudents)
|
foreach (var student in timeslot.UnscheduledStudents)
|
||||||
{
|
{
|
||||||
var studentName = student.FirstName;
|
var studentName = StudentNameFormatter.FormatStudentName(
|
||||||
if (_absentStudents.Contains(student))
|
student,
|
||||||
studentName += " (absent)";
|
new StudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
IsAbsent = _absentStudents.Contains(student)
|
||||||
|
});
|
||||||
|
|
||||||
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
||||||
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
||||||
@@ -414,4 +774,113 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class MeetingScheduleState : IEquatable<MeetingScheduleState>
|
||||||
|
{
|
||||||
|
public HashSet<int> ScheduledTeamIds { get; set; } = [];
|
||||||
|
public HashSet<int> AbsentStudentIds { get; set; } = [];
|
||||||
|
public int TimeSlotCount { get; set; }
|
||||||
|
public HashSet<int> ExtendedTeamIds { get; set; } = [];
|
||||||
|
public HashSet<(int teamId, int timeSlotIndex, int studentId)> ExcludedStudents { get; set; } = [];
|
||||||
|
|
||||||
|
public bool Equals(MeetingScheduleState? other)
|
||||||
|
{
|
||||||
|
if (other == null) return false;
|
||||||
|
return ScheduledTeamIds.SetEquals(other.ScheduledTeamIds) &&
|
||||||
|
AbsentStudentIds.SetEquals(other.AbsentStudentIds) &&
|
||||||
|
TimeSlotCount == other.TimeSlotCount &&
|
||||||
|
ExtendedTeamIds.SetEquals(other.ExtendedTeamIds) &&
|
||||||
|
ExcludedStudents.SetEquals(other.ExcludedStudents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object? obj) => Equals(obj as MeetingScheduleState);
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(ScheduledTeamIds.Count);
|
||||||
|
foreach (var id in ScheduledTeamIds.OrderBy(x => x))
|
||||||
|
hash.Add(id);
|
||||||
|
hash.Add(AbsentStudentIds.Count);
|
||||||
|
foreach (var id in AbsentStudentIds.OrderBy(x => x))
|
||||||
|
hash.Add(id);
|
||||||
|
hash.Add(TimeSlotCount);
|
||||||
|
hash.Add(ExtendedTeamIds.Count);
|
||||||
|
foreach (var id in ExtendedTeamIds.OrderBy(x => x))
|
||||||
|
hash.Add(id);
|
||||||
|
hash.Add(ExcludedStudents.Count);
|
||||||
|
foreach (var key in ExcludedStudents.OrderBy(x => x))
|
||||||
|
hash.Add(key);
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MeetingScheduleState FromCurrent(
|
||||||
|
IEnumerable<Team> scheduledTeams,
|
||||||
|
IEnumerable<Student> absentStudents,
|
||||||
|
int timeSlotCount,
|
||||||
|
IEnumerable<Team> extendedTeams,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
return new MeetingScheduleState
|
||||||
|
{
|
||||||
|
ScheduledTeamIds = scheduledTeams.Select(t => t.Id).ToHashSet(),
|
||||||
|
AbsentStudentIds = absentStudents.Select(s => s.Id).ToHashSet(),
|
||||||
|
TimeSlotCount = timeSlotCount,
|
||||||
|
ExtendedTeamIds = extendedTeams.Select(t => t.Id).ToHashSet(),
|
||||||
|
ExcludedStudents = excludedStudents.Keys
|
||||||
|
.Where(k => excludedStudents[k])
|
||||||
|
.ToHashSet()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<MeetingScheduleState?> FromLocalStorage(
|
||||||
|
LocalStorageService localStorage,
|
||||||
|
Team[] allTeams,
|
||||||
|
Student[] allStudents)
|
||||||
|
{
|
||||||
|
// Load scheduled teams
|
||||||
|
var scheduledTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ScheduledTeams");
|
||||||
|
var absentStudentIds = await localStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents");
|
||||||
|
var timeSlotCount = await localStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2);
|
||||||
|
var extendedTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ExtendedTeams");
|
||||||
|
var exclusions = await localStorage.GetJsonAsync<ExcludedStudent[]>("MeetingSchedule_ExcludedStudents");
|
||||||
|
|
||||||
|
// If no state exists, return null
|
||||||
|
if (scheduledTeamIds.Length == 0 && absentStudentIds.Length == 0 &&
|
||||||
|
timeSlotCount == 2 && extendedTeamIds.Length == 0 &&
|
||||||
|
(exclusions == null || exclusions.Length == 0))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var excludedStudentsSet = new HashSet<(int teamId, int timeSlotIndex, int studentId)>();
|
||||||
|
if (exclusions != null && exclusions.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (var exclusion in exclusions)
|
||||||
|
{
|
||||||
|
excludedStudentsSet.Add((exclusion.TeamId, exclusion.TimeSlotIndex, exclusion.StudentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MeetingScheduleState
|
||||||
|
{
|
||||||
|
ScheduledTeamIds = scheduledTeamIds.ToHashSet(),
|
||||||
|
AbsentStudentIds = absentStudentIds.ToHashSet(),
|
||||||
|
TimeSlotCount = timeSlotCount > 0 ? timeSlotCount : 2,
|
||||||
|
ExtendedTeamIds = extendedTeamIds.ToHashSet(),
|
||||||
|
ExcludedStudents = excludedStudentsSet
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveToLocalStorage(LocalStorageService localStorage)
|
||||||
|
{
|
||||||
|
await localStorage.SetIntArrayAsync("MeetingSchedule_ScheduledTeams", ScheduledTeamIds.ToArray());
|
||||||
|
await localStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", AbsentStudentIds.ToArray());
|
||||||
|
await localStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", TimeSlotCount);
|
||||||
|
await localStorage.SetIntArrayAsync("MeetingSchedule_ExtendedTeams", ExtendedTeamIds.ToArray());
|
||||||
|
|
||||||
|
var exclusions = ExcludedStudents.Select(e => new ExcludedStudent(e.teamId, e.timeSlotIndex, e.studentId)).ToArray();
|
||||||
|
await localStorage.SetJsonAsync("MeetingSchedule_ExcludedStudents", exclusions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,93 @@
|
|||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
|
@using Core.Utility
|
||||||
|
|
||||||
<MudStack>
|
<MudStack>
|
||||||
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
|
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
|
||||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
@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"
|
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
|
||||||
Class="d-flex align-center"
|
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||||
Color="Color.Default"
|
|
||||||
OnClick="@(() => OnToggleTeam.InvokeAsync(team))">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Class="@(removed ? "" : "d-none")">
|
Class="@(removed ? "" : "d-none")"
|
||||||
|
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
|
||||||
|
Style="cursor: pointer;">
|
||||||
</MudIcon>
|
</MudIcon>
|
||||||
@team -
|
@{
|
||||||
|
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||||
|
team,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||||
|
});
|
||||||
|
}
|
||||||
|
<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)
|
@foreach (var student in team.Students)
|
||||||
{
|
{
|
||||||
var overlap = StudentHasOverlaps(student);
|
var overlap = StudentHasOverlaps(student);
|
||||||
var isAbsent = AbsentStudents.Contains(student);
|
var chipColor = overlap ? Color.Warning : Color.Default;
|
||||||
var color = overlap ? Color.Warning : Color.Default;
|
var isExcluded = IsStudentExcluded(team.Id, TimeSlotIndex, student.Id);
|
||||||
var suffix = GetStudentSuffix(overlap, isAbsent);
|
var formattedName = TeamStudentNameFormatter.FormatStudentName(
|
||||||
|
student,
|
||||||
if (student != team.Students.First())
|
team,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
{
|
{
|
||||||
<MudText>, </MudText>
|
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>
|
||||||
}
|
}
|
||||||
<MudText Typo="Typo.body2" Color="@color">
|
else
|
||||||
@student.FirstName@suffix
|
{
|
||||||
</MudText>
|
<MudChip T="string"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="@chipColor"
|
||||||
|
Variant="@AppIcons.StudentChipVariant()"
|
||||||
|
Style="@(isExcluded ? "opacity: 0.5;" : "")">
|
||||||
|
<span>@formattedName</span>
|
||||||
|
</MudChip>
|
||||||
}
|
}
|
||||||
</MudLink>
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
@@ -39,6 +95,9 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string TimeSlotName { get; set; } = string.Empty;
|
public string TimeSlotName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int TimeSlotIndex { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public IEnumerable<Team> Teams { get; set; } = [];
|
public IEnumerable<Team> Teams { get; set; } = [];
|
||||||
|
|
||||||
@@ -51,13 +110,23 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
|
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> ExcludedStudents { get; set; } = new();
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<Team> OnToggleTeam { get; set; }
|
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 ? "*" : "";
|
var key = (teamId, timeSlotIndex, studentId);
|
||||||
suffix += isAbsent ? " (absent)" : "";
|
return ExcludedStudents.ContainsKey(key) && ExcludedStudents[key];
|
||||||
return suffix;
|
}
|
||||||
|
|
||||||
|
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.Calculation
|
||||||
|
@using Core.Utility
|
||||||
|
@using WebApp.Models
|
||||||
|
|
||||||
@if (UnscheduledStudents.Any())
|
@if (UnscheduledStudents.Any())
|
||||||
{
|
{
|
||||||
@@ -6,31 +8,52 @@
|
|||||||
<MudStack>
|
<MudStack>
|
||||||
@foreach (var student in UnscheduledStudents)
|
@foreach (var student in UnscheduledStudents)
|
||||||
{
|
{
|
||||||
var isAbsent = AbsentStudents.Contains(student);
|
|
||||||
<MudItem>
|
<MudItem>
|
||||||
<MudText Typo="Typo.body1" HtmlTag="i">
|
@{
|
||||||
@student.FirstName@(isAbsent ? " (absent)" : "")
|
var formattedName = StudentNameFormatter.FormatStudentName(
|
||||||
</MudText>
|
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))
|
@foreach (var unassignedTeam in UnassignedTeams(student))
|
||||||
{
|
{
|
||||||
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
||||||
var isScheduled = ScheduledTeams.Contains(unassignedTeam);
|
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
|
||||||
var color = isPossibleAddition ? Color.Success : Color.Default;
|
var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id);
|
||||||
|
var chipColor = isPossibleAddition ? Color.Success : Color.Default;
|
||||||
if (unassignedTeam != UnassignedTeams(student).First())
|
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||||
|
unassignedTeam,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
{
|
{
|
||||||
<span>, </span>
|
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||||
}
|
});
|
||||||
<MudLink Typo="Typo.body2"
|
|
||||||
Color="@color"
|
<MudTooltip Text="@teamMembers">
|
||||||
OnClick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))">
|
<div @onclick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))" style="cursor: pointer; display: inline-block;">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Check"
|
<MudChip T="string"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Class="@(isScheduled ? "" : "d-none")">
|
Color="@chipColor"
|
||||||
</MudIcon>
|
Variant="@AppIcons.TeamChipVariant()">
|
||||||
@unassignedTeam
|
@if (isScheduled)
|
||||||
</MudLink>
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Style="margin-right: 4px;" />
|
||||||
}
|
}
|
||||||
|
@unassignedTeam
|
||||||
|
</MudChip>
|
||||||
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -140,11 +140,13 @@ else
|
|||||||
.OrderBy(e => e.Event.Name)
|
.OrderBy(e => e.Event.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var events = await Context.Events.ToArrayAsync();
|
var events = await Context.Events
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToArrayAsync();
|
||||||
var remainingEvents =
|
var remainingEvents =
|
||||||
events
|
events
|
||||||
.Where(e => _eventStudentRankings.All(est => est.Event.Id != e.Id))
|
.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)
|
.OrderBy(e => e.Event.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/students"
|
@page "/students"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@implements IAsyncDisposable
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
<PropertyColumn Property="@(e => e.LastName)" Title="Name" Sortable="true">
|
<PropertyColumn Property="@(e => e.LastName)" Title="Name" Sortable="true">
|
||||||
<CellTemplate>
|
<CellTemplate>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
<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")"
|
<MudLink Href="@($"/students/details?id={context.Item.Id}&returnUrl=/students")"
|
||||||
Underline="Underline.Hover"
|
Underline="Underline.Hover"
|
||||||
Color="Color.Primary">
|
Color="Color.Primary">
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
{
|
{
|
||||||
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
|
<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">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||||
TooltipText="Edit"
|
TooltipText="Edit"
|
||||||
@@ -68,18 +69,34 @@
|
|||||||
@code {
|
@code {
|
||||||
MudDataGrid<Student> _dataGrid = null!;
|
MudDataGrid<Student> _dataGrid = null!;
|
||||||
private bool _isLoading = true;
|
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)
|
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
|
||||||
var query =
|
var query =
|
||||||
Context.Students.OrderBy(e => e.LastName)
|
Context.Students
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(e => e.LastName)
|
||||||
.Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
.Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||||
|
|
||||||
var totalItems = await query.CountAsync();
|
var totalItems = await query.CountAsync(cancellationToken);
|
||||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
|
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
return new GridData<Student>
|
return new GridData<Student>
|
||||||
{
|
{
|
||||||
@@ -87,15 +104,30 @@
|
|||||||
Items = pagedData
|
Items = pagedData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
{
|
{
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeleteStudent(Student student)
|
private async Task DeleteStudent(Student student)
|
||||||
{
|
{
|
||||||
//_isRowBlocked = true;
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
|
||||||
var result = await DialogService
|
var result = await DialogService
|
||||||
.ShowMessageBox("Delete student",
|
.ShowMessageBox("Delete student",
|
||||||
@@ -103,15 +135,66 @@
|
|||||||
yesText:"Yes",
|
yesText:"Yes",
|
||||||
noText:"Cancel");
|
noText:"Cancel");
|
||||||
|
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
if (result == true)
|
if (result == true)
|
||||||
{
|
{
|
||||||
Context.Students.Remove(student!);
|
// Load the student fresh from database with tracking to avoid tracking conflicts
|
||||||
await Context.SaveChangesAsync();
|
var studentToDelete = await Context.Students
|
||||||
Snackbar.Add($"Delete event: Delete of Student {student.Name}", Severity.Info);
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
//_isRowBlocked = false;
|
Context.Students.Remove(studentToDelete);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Student {studentToDelete.Name} deleted", Severity.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await _dataGrid.ReloadServerData();
|
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,6 +4,7 @@
|
|||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@using Core.Validation
|
@using Core.Validation
|
||||||
|
@using Core.Utility
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject WebApp.LocalStorageService LocalStorage
|
@inject WebApp.LocalStorageService LocalStorage
|
||||||
@inject ValidationService ValidationService
|
@inject ValidationService ValidationService
|
||||||
@@ -79,16 +80,22 @@
|
|||||||
? context.Item.Teams.Where(t => t?.Event is { RegionalEvent: true }).OrderBy(t => t.Event.Name)
|
? 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);
|
: 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)
|
@foreach (var team in teamsToDisplay)
|
||||||
{
|
{
|
||||||
var isCaptain = team.Captain != null && team.Captain.Equals(context.Item.Student);
|
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">
|
<MudTooltip Text="@teamMembers">
|
||||||
<MudChip Size="Size.Small"
|
<MudChip Size="Size.Small"
|
||||||
Color="Color.Default"
|
Color="Color.Default"
|
||||||
Href="@($"/teams/edit?id={team.Id}")"
|
Variant="@AppIcons.TeamChipVariant()"
|
||||||
|
Href="@($"/teams/edit?id={team.Id}&returnUrl=/students/teams")"
|
||||||
Style="cursor: pointer;">
|
Style="cursor: pointer;">
|
||||||
@team
|
@team
|
||||||
@if (isCaptain && team.Event.EventFormat != EventFormat.Individual)
|
@if (isCaptain && team.Event.EventFormat != EventFormat.Individual)
|
||||||
@@ -98,7 +105,7 @@
|
|||||||
</MudChip>
|
</MudChip>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
}
|
}
|
||||||
</div>
|
</MudStack>
|
||||||
</CellTemplate>
|
</CellTemplate>
|
||||||
</TemplateColumn>
|
</TemplateColumn>
|
||||||
</Columns>
|
</Columns>
|
||||||
@@ -172,14 +179,38 @@
|
|||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
try
|
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
|
var students = await Context.Students
|
||||||
|
.AsNoTracking()
|
||||||
.Include(s => s.Teams)
|
.Include(s => s.Teams)
|
||||||
.ThenInclude(t => t.Event)
|
.ThenInclude(t => t.Event)
|
||||||
.Include(s => s.Teams)
|
.Include(s => s.Teams)
|
||||||
.ThenInclude(t => t.Captain)
|
.ThenInclude(t => t.Captain)
|
||||||
.ToListAsync();
|
.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
|
// Filter to only students with teams
|
||||||
var studentTeams = students
|
var studentTeams = students
|
||||||
.Where(s => s.Teams.Any(t => t?.Event != null && (!_showRegionalOnly || t.Event.RegionalEvent)))
|
.Where(s => s.Teams.Any(t => t?.Event != null && (!_showRegionalOnly || t.Event.RegionalEvent)))
|
||||||
@@ -270,7 +301,7 @@
|
|||||||
Student = student,
|
Student = student,
|
||||||
Events = student.Teams?.Where(t => t?.Event != null)
|
Events = student.Teams?.Where(t => t?.Event != null)
|
||||||
.Select(t => t.Event)
|
.Select(t => t.Event)
|
||||||
.ToList() ?? new List<EventDefinition>()
|
.ToList() ?? []
|
||||||
};
|
};
|
||||||
|
|
||||||
return ValidationService.ValidateStudentStatistics(stats, ValidationContext.StudentRegistration);
|
return ValidationService.ValidateStudentStatistics(stats, ValidationContext.StudentRegistration);
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
.Concat(context.Events)
|
.Concat(context.Events)
|
||||||
.Distinct();
|
.Distinct();
|
||||||
}
|
}
|
||||||
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||||
@foreach (var e in
|
@foreach (var e in
|
||||||
allStudentEvents
|
allStudentEvents
|
||||||
.OrderBy(e =>
|
.OrderBy(e =>
|
||||||
@@ -163,7 +164,7 @@
|
|||||||
var isAssigned = context.Events.Contains(e);
|
var isAssigned = context.Events.Contains(e);
|
||||||
|
|
||||||
var color = AppIcons.RankedEventColor(eventRank ?? 0);
|
var color = AppIcons.RankedEventColor(eventRank ?? 0);
|
||||||
var style = string.Empty;
|
var style = "border-style: solid;";
|
||||||
|
|
||||||
if (isAssigned)
|
if (isAssigned)
|
||||||
{
|
{
|
||||||
@@ -183,10 +184,6 @@
|
|||||||
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
|
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
|
||||||
}
|
}
|
||||||
|
|
||||||
<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
|
var isIncluded = _assignmentRequirements
|
||||||
.Find(ar =>
|
.Find(ar =>
|
||||||
ar.EventDefinition == e
|
ar.EventDefinition == e
|
||||||
@@ -197,7 +194,13 @@
|
|||||||
ar.EventDefinition == e
|
ar.EventDefinition == e
|
||||||
&& ar.Student == context.Student
|
&& ar.Student == context.Student
|
||||||
&& ar.Requirement == Requirement.Exclude) == null;
|
&& ar.Requirement == Requirement.Exclude) == null;
|
||||||
}
|
|
||||||
|
<InteractiveChip WrapperClass="my-1" Style="@style" Variant="@AppIcons.EventChipVariant()">
|
||||||
|
<ChildContent>
|
||||||
|
@e.ShortName
|
||||||
|
@AppIcons.EventAttributes(e)
|
||||||
|
</ChildContent>
|
||||||
|
<ControlContent>
|
||||||
@if (isIncluded)
|
@if (isIncluded)
|
||||||
{
|
{
|
||||||
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||||
@@ -227,8 +230,10 @@
|
|||||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</ControlContent>
|
||||||
|
</InteractiveChip>
|
||||||
}
|
}
|
||||||
|
</MudStack>
|
||||||
<MudDivider Style="border-width:3px" />
|
<MudDivider Style="border-width:3px" />
|
||||||
</td></MudTr>
|
</td></MudTr>
|
||||||
</ChildRowContent>
|
</ChildRowContent>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
@using WebApp.Models
|
||||||
|
@using Core.Utility
|
||||||
|
|
||||||
|
@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);
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
@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
|
@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
|
@foreach (var student in
|
||||||
Team.Students
|
Team.Students
|
||||||
.OrderBy(e =>
|
.OrderBy(e =>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<MudTooltip Text="@rankLabel">
|
<MudTooltip Text="@rankLabel">
|
||||||
<MudChip T="string"
|
<MudChip T="string"
|
||||||
Size="Size.Medium"
|
Size="Size.Medium"
|
||||||
Variant="Variant.Outlined"
|
Variant="@AppIcons.StudentChipVariant()"
|
||||||
Class="mx-1 my-1">
|
Class="mx-1 my-1">
|
||||||
@if (eventRank.HasValue)
|
@if (eventRank.HasValue)
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</MudChip>
|
</MudChip>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
}
|
}
|
||||||
</div>
|
</MudStack>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
|
@using Core.Utility
|
||||||
|
|
||||||
@if (Title != null)
|
@if (Title != null)
|
||||||
{
|
{
|
||||||
@@ -14,9 +15,15 @@
|
|||||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.Event.Name))
|
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.Event.Name))
|
||||||
{
|
{
|
||||||
<MudToggleItem Value="@team" Style="font-size: .75rem;">
|
<MudToggleItem Value="@team" Style="font-size: .75rem;">
|
||||||
<MudTooltip Text="@team.StudentsFirstNames">
|
<MudTooltip Text="@TeamStudentNameFormatter.FormatStudentList(
|
||||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
team,
|
||||||
<MudText Class="ellipsis">@team.ToString()</MudText>
|
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)
|
@if (ShowEventAttributes)
|
||||||
{
|
{
|
||||||
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
|
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
|
||||||
|
|||||||
@@ -71,7 +71,9 @@
|
|||||||
.Include(e => e.Event)
|
.Include(e => e.Event)
|
||||||
.Include(e => e.Students)
|
.Include(e => e.Students)
|
||||||
.FirstOrDefaultAsync(m => m.Id == Id);
|
.FirstOrDefaultAsync(m => m.Id == Id);
|
||||||
_students = await Context.Students.ToListAsync();
|
_students = await Context.Students
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync();
|
||||||
_selectedStudents = Team?.Students.ToList();
|
_selectedStudents = Team?.Students.ToList();
|
||||||
|
|
||||||
if (Team is null)
|
if (Team is null)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using Core.Utility
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
|
|
||||||
@@ -35,7 +36,12 @@ else
|
|||||||
@if (team.Event.EventFormat == EventFormat.Team)
|
@if (team.Event.EventFormat == EventFormat.Team)
|
||||||
{
|
{
|
||||||
<span style="font-weight: normal; font-size: 0.9em;">
|
<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>
|
</span>
|
||||||
}
|
}
|
||||||
</MudText>
|
</MudText>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using Core.Utility
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
|
|
||||||
@@ -57,7 +58,12 @@ else
|
|||||||
{
|
{
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudText Class="d-flex py-1" Typo="Typo.h6">
|
<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>
|
</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@page "/teams"
|
@page "/teams"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@implements IAsyncDisposable
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
<TemplateColumn Title="Event" Sortable="true" SortBy="@(t => t.Event.Name)">
|
<TemplateColumn Title="Event" Sortable="true" SortBy="@(t => t.Event.Name)">
|
||||||
<CellTemplate>
|
<CellTemplate>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||||
<MudLink Href="@($"/teams/details?id={context.Item.Id}")"
|
<MudLink Href="@($"/teams/details?id={context.Item.Id}&returnUrl=/teams")"
|
||||||
Underline="Underline.Hover"
|
Underline="Underline.Hover"
|
||||||
Color="Color.Primary">
|
Color="Color.Primary">
|
||||||
@context.Item.ToString()
|
@context.Item.ToString()
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||||
TooltipText="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"
|
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
||||||
TooltipText="Delete"
|
TooltipText="Delete"
|
||||||
HoverColor="Color.Error"
|
HoverColor="Color.Error"
|
||||||
@@ -74,31 +75,60 @@
|
|||||||
MudDataGrid<Team> _dataGrid = null!;
|
MudDataGrid<Team> _dataGrid = null!;
|
||||||
private bool _isLoading = true;
|
private bool _isLoading = true;
|
||||||
private bool _showRegionalOnly = false;
|
private bool _showRegionalOnly = false;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ToggleRegionalFilter()
|
private async Task ToggleRegionalFilter()
|
||||||
{
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_showRegionalOnly = !_showRegionalOnly;
|
_showRegionalOnly = !_showRegionalOnly;
|
||||||
if (_dataGrid != null)
|
if (_dataGrid != null && !_isDisposed)
|
||||||
{
|
{
|
||||||
await _dataGrid.ReloadServerData();
|
await _dataGrid.ReloadServerData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
|
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<GridData<Team>> ServerReload(GridState<Team> state)
|
private async Task<GridData<Team>> ServerReload(GridState<Team> state)
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return new GridData<Team> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
|
||||||
IQueryable<Team> query
|
IQueryable<Team> query
|
||||||
= Context.Teams
|
= Context.Teams
|
||||||
|
.AsNoTracking()
|
||||||
.Include(e => e.Event)
|
.Include(e => e.Event)
|
||||||
|
.Include(e => e.Captain)
|
||||||
.Include(e => e.Students)
|
.Include(e => e.Students)
|
||||||
.ThenInclude(e => e.EventRankings);
|
.ThenInclude(e => e.EventRankings);
|
||||||
|
|
||||||
@@ -113,7 +143,7 @@
|
|||||||
query = query.Where(state.FilterDefinitions);
|
query = query.Where(state.FilterDefinitions);
|
||||||
|
|
||||||
// Load all data first
|
// 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
|
// Always sort by EventFormat FIRST to separate group/individual teams
|
||||||
// Sort in memory to ensure this ordering is maintained regardless of user sorts
|
// Sort in memory to ensure this ordering is maintained regardless of user sorts
|
||||||
@@ -173,15 +203,32 @@
|
|||||||
Items = pagedData
|
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
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
{
|
{
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeleteTeam(Team team)
|
private async Task DeleteTeam(Team team)
|
||||||
{
|
{
|
||||||
//_isRowBlocked = true;
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
|
||||||
var result = await DialogService
|
var result = await DialogService
|
||||||
.ShowMessageBox("Delete team",
|
.ShowMessageBox("Delete team",
|
||||||
@@ -189,14 +236,35 @@
|
|||||||
yesText: "Yes",
|
yesText: "Yes",
|
||||||
noText: "Cancel");
|
noText: "Cancel");
|
||||||
|
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
if (result == true)
|
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)
|
||||||
|
{
|
||||||
|
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 deleting a numbered team (1 or 2), clear the identifier of the remaining team
|
||||||
if (team.Identifier == "1" || team.Identifier == "2")
|
if (teamToDelete.Identifier == "1" || teamToDelete.Identifier == "2")
|
||||||
{
|
{
|
||||||
var remainingTeam = await Context.Teams
|
var remainingTeam = await Context.Teams
|
||||||
.Include(t => t.Event)
|
.Include(t => t.Event)
|
||||||
.FirstOrDefaultAsync(t => t.Event.Id == team.Event.Id && t.Id != team.Id);
|
.FirstOrDefaultAsync(t => t.Event.Id == teamToDelete.Event.Id && t.Id != teamToDelete.Id, cancellationToken);
|
||||||
|
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
if (remainingTeam != null)
|
if (remainingTeam != null)
|
||||||
{
|
{
|
||||||
@@ -205,13 +273,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Context.Teams.Remove(team!);
|
Context.Teams.Remove(teamToDelete);
|
||||||
await Context.SaveChangesAsync();
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
Snackbar.Add($"Delete event: Delete of Team {team}", Severity.Info);
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Team {teamToDelete} deleted", Severity.Info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//_isRowBlocked = false;
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await _dataGrid.ReloadServerData();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using Core.Utility
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
|
|
||||||
@@ -56,7 +57,13 @@ else
|
|||||||
.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue;
|
.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue;
|
||||||
|
|
||||||
<MudTd Class="@(EventRankClass(rank))">
|
<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>
|
</MudTd>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -191,7 +198,12 @@ else
|
|||||||
<span>❔</span>
|
<span>❔</span>
|
||||||
}
|
}
|
||||||
</MudTd>
|
</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>
|
<MudTd></MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
@@ -218,6 +230,7 @@ else
|
|||||||
{
|
{
|
||||||
_teams
|
_teams
|
||||||
= await Context.Teams
|
= await Context.Teams
|
||||||
|
.AsNoTracking()
|
||||||
.Include(e => e.Event)
|
.Include(e => e.Event)
|
||||||
.Include(e => e.Students)
|
.Include(e => e.Students)
|
||||||
.OrderByEventFormatFirst()
|
.OrderByEventFormatFirst()
|
||||||
@@ -228,6 +241,7 @@ else
|
|||||||
_maxTeamSize = _teams.Max(t => t.Students.Count);
|
_maxTeamSize = _teams.Max(t => t.Students.Count);
|
||||||
_students =
|
_students =
|
||||||
await Context.Students
|
await Context.Students
|
||||||
|
.AsNoTracking()
|
||||||
.Include(e => e.Teams)
|
.Include(e => e.Teams)
|
||||||
.ThenInclude(e => e.Captain)
|
.ThenInclude(e => e.Captain)
|
||||||
.Include(e => e.EventRankings)
|
.Include(e => e.EventRankings)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@using Core.Entities
|
||||||
|
@using WebApp.Services
|
||||||
|
@using PSC.Blazor.Components.MarkdownEditor
|
||||||
|
@using MudBlazor
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudTextField @bind-Value="_note.Title"
|
||||||
|
Label="Title"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Required="true"
|
||||||
|
RequiredError="Title is required"
|
||||||
|
MaxLength="200" />
|
||||||
|
|
||||||
|
<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!;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_note = new Note
|
||||||
|
{
|
||||||
|
Id = Note.Id,
|
||||||
|
Title = Note.Title ?? "",
|
||||||
|
Content = Note.Content ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
@using Core.Entities
|
||||||
|
@using WebApp.Services
|
||||||
|
@using MudBlazor
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<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,
|
||||||
|
_ => Color.Default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ViewVersion(NoteHistory history)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["History"] = history
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Large,
|
||||||
|
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>
|
||||||
|
<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,260 @@
|
|||||||
|
@page "/notes"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@using Core.Entities
|
||||||
|
@using WebApp.Services
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageHeader Title="Notes">
|
||||||
|
<ActionButtons>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
OnClick="OpenCreateDialog"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary">
|
||||||
|
Create Note
|
||||||
|
</MudButton>
|
||||||
|
</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
|
||||||
|
{
|
||||||
|
<MudExpansionPanels MultiExpansion="true">
|
||||||
|
@foreach (var note in _notes)
|
||||||
|
{
|
||||||
|
<MudExpansionPanel Text="@note.Title"
|
||||||
|
Icon="@Icons.Material.Filled.Note">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
}
|
||||||
|
</MudExpansionPanels>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<Note> _notes = [];
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadNotes()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
_notes = (await NotesService.GetNotesAsync()).ToList();
|
||||||
|
}
|
||||||
|
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 async Task OpenCreateDialog()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["Note"] = new Note { Title = "", Content = "" },
|
||||||
|
["IsEdit"] = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Large,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, options);
|
||||||
|
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.Large,
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Large,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await DialogService.ShowAsync<NoteHistoryDialog>("Note History", parameters, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>? This cannot be undone.",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)">
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||||
<NotAuthorized>
|
<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>
|
</NotAuthorized>
|
||||||
</AuthorizeRouteView>
|
</AuthorizeRouteView>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
|
||||||
|
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center"
|
||||||
|
Style="position: relative; display: inline-flex;"
|
||||||
|
Class="@WrapperClass"
|
||||||
|
@onmouseenter="@(() => _isHovered = true)"
|
||||||
|
@onmouseleave="@(() => _isHovered = false)">
|
||||||
|
<MudChip T="string"
|
||||||
|
Size="@Size"
|
||||||
|
Color="@Color"
|
||||||
|
Variant="@Variant"
|
||||||
|
Style="@Style"
|
||||||
|
Class="@Class">
|
||||||
|
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||||
|
@ChildContent
|
||||||
|
@if (_isHovered && ControlContent != null)
|
||||||
|
{
|
||||||
|
@ControlContent
|
||||||
|
}
|
||||||
|
</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; }
|
||||||
|
|
||||||
|
private bool _isHovered = false;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Authentication
|
@using WebApp.Authentication
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@
|
|||||||
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
|
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
|
|
||||||
|
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
|
||||||
|
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
|
||||||
|
</MudNavGroup>
|
||||||
|
|
||||||
<AuthorizeView Roles="Administrator">
|
<AuthorizeView Roles="Administrator">
|
||||||
<MudDivider Class="my-2"/>
|
<MudDivider Class="my-2"/>
|
||||||
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
|
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using System.Net.Http
|
@using System.Net.Http
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@@ -28,3 +28,5 @@
|
|||||||
@using Core.Models
|
@using Core.Models
|
||||||
@using Data
|
@using Data
|
||||||
@using VisNetwork.Blazor
|
@using VisNetwork.Blazor
|
||||||
|
@using PSC.Blazor.Components.MarkdownEditor
|
||||||
|
@using WebApp.Services
|
||||||
@@ -9,10 +9,12 @@ namespace WebApp;
|
|||||||
public sealed class LocalStorageService
|
public sealed class LocalStorageService
|
||||||
{
|
{
|
||||||
private readonly IJSRuntime _jsRuntime;
|
private readonly IJSRuntime _jsRuntime;
|
||||||
|
private readonly ILogger<LocalStorageService> _logger;
|
||||||
|
|
||||||
public LocalStorageService(IJSRuntime jsRuntime)
|
public LocalStorageService(IJSRuntime jsRuntime, ILogger<LocalStorageService> logger)
|
||||||
{
|
{
|
||||||
_jsRuntime = jsRuntime;
|
_jsRuntime = jsRuntime;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -47,7 +49,7 @@ public sealed class LocalStorageService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
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))
|
if (!string.IsNullOrEmpty(json))
|
||||||
{
|
{
|
||||||
var array = JsonSerializer.Deserialize<int[]>(json);
|
var array = JsonSerializer.Deserialize<int[]>(json);
|
||||||
return array ?? Array.Empty<int>();
|
return array ?? [];
|
||||||
}
|
}
|
||||||
return Array.Empty<int>();
|
return [];
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Failed to load integer array from localStorage [{key}]: {ex.Message}");
|
_logger.LogWarning(ex, "Failed to load integer array from localStorage [{Key}]", key);
|
||||||
return Array.Empty<int>();
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ public sealed class LocalStorageService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
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)
|
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,
|
LogEventLevel.Information,
|
||||||
null, // No exception at Info level
|
null, // No exception at Info level
|
||||||
messageTemplate,
|
messageTemplate,
|
||||||
new[]
|
[
|
||||||
{
|
|
||||||
new LogEventProperty("OriginalMessage",
|
new LogEventProperty("OriginalMessage",
|
||||||
new ScalarValue(logEvent.MessageTemplate.Render(logEvent.Properties))),
|
new ScalarValue(logEvent.MessageTemplate.Render(logEvent.Properties))),
|
||||||
new LogEventProperty("SourceContext",
|
new LogEventProperty("SourceContext",
|
||||||
logEvent.Properties.GetValueOrDefault("SourceContext") ?? new ScalarValue("Unknown")),
|
logEvent.Properties.GetValueOrDefault("SourceContext") ?? new ScalarValue("Unknown")),
|
||||||
new LogEventProperty("RequestPath",
|
new LogEventProperty("RequestPath",
|
||||||
logEvent.Properties.GetValueOrDefault("RequestPath") ?? new ScalarValue("Unknown"))
|
logEvent.Properties.GetValueOrDefault("RequestPath") ?? new ScalarValue("Unknown"))
|
||||||
});
|
]);
|
||||||
|
|
||||||
_wrappedSink.Emit(rewrittenEvent);
|
_wrappedSink.Emit(rewrittenEvent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,5 +154,20 @@ namespace WebApp.Models
|
|||||||
};
|
};
|
||||||
return $"{number}<sup>{suffix}</sup>";
|
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;
|
EventDefinition = occurrence.EventDefinition;
|
||||||
// Set base class properties that the calendar component uses
|
// Set base class properties that the calendar component uses
|
||||||
StudentFirstNames = studentFirstNames?.ToList() ?? [];
|
StudentFirstNames = studentFirstNames?.ToList() ?? [];
|
||||||
Text = occurrence.EventDefinition?.ShortName;
|
Text = occurrence.EventDefinition?.ShortName ?? string.Empty;
|
||||||
Start = occurrence.StartTime;
|
Start = occurrence.StartTime;
|
||||||
End = occurrence.EndTime ?? occurrence.StartTime.AddHours(1);
|
End = occurrence.EndTime ?? occurrence.StartTime.AddHours(1);
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
||||||
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
|
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
|
||||||
|
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
|
||||||
|
|
||||||
// State container for maintaining state per user connection (Blazor Server)
|
// State container for maintaining state per user connection (Blazor Server)
|
||||||
builder.Services.AddScoped<StateContainer>();
|
builder.Services.AddScoped<StateContainer>();
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class EventDefinitionService
|
|||||||
|
|
||||||
// Get all existing careers from database (case-insensitive lookup)
|
// Get all existing careers from database (case-insensitive lookup)
|
||||||
var existingCareers = await _context.Careers
|
var existingCareers = await _context.Careers
|
||||||
|
.AsNoTracking()
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
public interface INotesService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all notes.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<Note>> GetNotesAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a single note by ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<Note?> GetNoteAsync(int id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all history entries for a note.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new note and initial history entry.
|
||||||
|
/// </summary>
|
||||||
|
Task<Note> CreateNoteAsync(Note note);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing note and creates a history entry.
|
||||||
|
/// </summary>
|
||||||
|
Task<Note> UpdateNoteAsync(Note note);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a note and creates a deletion history entry.
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteNoteAsync(int id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Markdig;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class for rendering markdown to HTML.
|
||||||
|
/// </summary>
|
||||||
|
public static class MarkdownHelper
|
||||||
|
{
|
||||||
|
private static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder()
|
||||||
|
.UseAdvancedExtensions()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts markdown text to HTML.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="markdown">The markdown text to convert.</param>
|
||||||
|
/// <returns>HTML string ready for rendering.</returns>
|
||||||
|
public static string ToHtml(string? markdown)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(markdown))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var html = Markdown.ToHtml(markdown, _pipeline);
|
||||||
|
|
||||||
|
// Add target="_blank" and rel="noopener noreferrer" to all links
|
||||||
|
html = Regex.Replace(html, @"<a\s+([^>]*?)href=""([^""]*?)""([^>]*?)>",
|
||||||
|
match =>
|
||||||
|
{
|
||||||
|
var beforeHref = match.Groups[1].Value;
|
||||||
|
var href = match.Groups[2].Value;
|
||||||
|
var afterHref = match.Groups[3].Value;
|
||||||
|
|
||||||
|
// Check if target is already present
|
||||||
|
if (Regex.IsMatch(beforeHref + afterHref, @"target\s*=", RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
return match.Value; // Return unchanged if target already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add target="_blank" and rel="noopener noreferrer"
|
||||||
|
return $"<a {beforeHref}href=\"{href}\" target=\"_blank\" rel=\"noopener noreferrer\"{afterHref}>";
|
||||||
|
},
|
||||||
|
RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace WebApp.Services;
|
||||||
|
|
||||||
|
public class NotesService : INotesService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly ILogger<NotesService> _logger;
|
||||||
|
|
||||||
|
public NotesService(
|
||||||
|
AppDbContext context,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ILogger<NotesService> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GetCurrentUserEmail()
|
||||||
|
{
|
||||||
|
var user = _httpContextAccessor.HttpContext?.User;
|
||||||
|
if (user == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Note>> GetNotesAsync()
|
||||||
|
{
|
||||||
|
return await _context.Notes
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(n => n.UpdatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Note?> GetNoteAsync(int id)
|
||||||
|
{
|
||||||
|
return await _context.Notes
|
||||||
|
.Include(n => n.NoteHistories.OrderByDescending(h => h.ModifiedAt))
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
|
||||||
|
{
|
||||||
|
return await _context.NoteHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(h => h.NoteId == noteId)
|
||||||
|
.OrderByDescending(h => h.ModifiedAt)
|
||||||
|
.Take(10)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Note> CreateNoteAsync(Note note)
|
||||||
|
{
|
||||||
|
var userEmail = GetCurrentUserEmail();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
note.CreatedAt = now;
|
||||||
|
note.UpdatedAt = now;
|
||||||
|
note.CreatedBy = userEmail;
|
||||||
|
note.LastModifiedBy = userEmail;
|
||||||
|
|
||||||
|
_context.Notes.Add(note);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Create initial history entry
|
||||||
|
var history = new NoteHistory
|
||||||
|
{
|
||||||
|
NoteId = note.Id,
|
||||||
|
Title = note.Title,
|
||||||
|
Content = note.Content,
|
||||||
|
ModifiedBy = userEmail,
|
||||||
|
ModifiedAt = now,
|
||||||
|
ChangeType = "Created"
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.NoteHistories.Add(history);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Note created: {NoteId} by {User}", note.Id, userEmail);
|
||||||
|
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Note> UpdateNoteAsync(Note note)
|
||||||
|
{
|
||||||
|
var existingNote = await _context.Notes
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == note.Id);
|
||||||
|
|
||||||
|
if (existingNote == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Note with ID {note.Id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userEmail = GetCurrentUserEmail();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Create history entry with previous state before updating
|
||||||
|
var history = new NoteHistory
|
||||||
|
{
|
||||||
|
NoteId = existingNote.Id,
|
||||||
|
Title = existingNote.Title,
|
||||||
|
Content = existingNote.Content,
|
||||||
|
ModifiedBy = userEmail,
|
||||||
|
ModifiedAt = now,
|
||||||
|
ChangeType = "Updated"
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.NoteHistories.Add(history);
|
||||||
|
|
||||||
|
// Update the note
|
||||||
|
existingNote.Title = note.Title;
|
||||||
|
existingNote.Content = note.Content;
|
||||||
|
existingNote.UpdatedAt = now;
|
||||||
|
existingNote.LastModifiedBy = userEmail;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Note updated: {NoteId} by {User}", note.Id, userEmail);
|
||||||
|
|
||||||
|
return existingNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteNoteAsync(int id)
|
||||||
|
{
|
||||||
|
var note = await _context.Notes
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == id);
|
||||||
|
|
||||||
|
if (note == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Note with ID {id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userEmail = GetCurrentUserEmail();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Create deletion history entry
|
||||||
|
var history = new NoteHistory
|
||||||
|
{
|
||||||
|
NoteId = note.Id,
|
||||||
|
Title = note.Title,
|
||||||
|
Content = note.Content,
|
||||||
|
ModifiedBy = userEmail,
|
||||||
|
ModifiedAt = now,
|
||||||
|
ChangeType = "Deleted"
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.NoteHistories.Add(history);
|
||||||
|
|
||||||
|
// Delete the note (cascade will handle history, but we want to keep history)
|
||||||
|
_context.Notes.Remove(note);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Note deleted: {NoteId} by {User}", id, userEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
@@ -29,10 +29,12 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
||||||
<PackageReference Include="MudBlazor" Version="8.14.0" />
|
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
<PackageReference Include="PSC.Blazor.Components.MarkdownEditor" Version="8.0.6" />
|
||||||
|
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -129,3 +129,104 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown content styling */
|
||||||
|
.markdown-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2,
|
||||||
|
.markdown-content h3,
|
||||||
|
.markdown-content h4,
|
||||||
|
.markdown-content h5,
|
||||||
|
.markdown-content h6 {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
border-bottom: 1px solid var(--mud-palette-divider);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
border-bottom: 1px solid var(--mud-palette-divider);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin: 1em 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: var(--mud-palette-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
background-color: var(--mud-palette-background-grey);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
background-color: var(--mud-palette-background-grey);
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 4px solid var(--mud-palette-primary);
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: var(--mud-palette-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table th,
|
||||||
|
.markdown-content table td {
|
||||||
|
border: 1px solid var(--mud-palette-divider);
|
||||||
|
padding: 0.5em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table th {
|
||||||
|
background-color: var(--mud-palette-background-grey);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user