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/
|
||||
*.db
|
||||
/.claude/*
|
||||
/.*/*
|
||||
.*rules
|
||||
|
||||
# Production secrets and configuration
|
||||
auth-secrets.json
|
||||
@@ -34,3 +35,4 @@ docker-compose.override.yml
|
||||
|
||||
# Runtime data directory
|
||||
/WebApp/Data/*
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ namespace Core.Calculation
|
||||
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
||||
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
||||
{
|
||||
var buffer = new List<ILiteral>();
|
||||
List<ILiteral> buffer = [];
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
buffer.Clear();
|
||||
@@ -273,7 +273,7 @@ namespace Core.Calculation
|
||||
/// </summary>
|
||||
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
||||
{
|
||||
var prohibitVar = new List<IntVar>(1);
|
||||
List<IntVar> prohibitVar = [];
|
||||
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
@@ -328,7 +328,7 @@ namespace Core.Calculation
|
||||
/// </summary>
|
||||
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
||||
{
|
||||
var studentCapacity = new List<IntVar>();
|
||||
List<IntVar> studentCapacity = [];
|
||||
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
@@ -418,7 +418,7 @@ namespace Core.Calculation
|
||||
/// </summary>
|
||||
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
||||
{
|
||||
var eventCapacity = new List<IntVar>();
|
||||
List<IntVar> eventCapacity = [];
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
eventCapacity.Add(x[eventIndex, s]);
|
||||
@@ -455,7 +455,7 @@ namespace Core.Calculation
|
||||
model.AddAssumption(x[e, s]);
|
||||
}
|
||||
|
||||
var prohibitVar = new List<IntVar>(1);
|
||||
List<IntVar> prohibitVar = [];
|
||||
foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude))
|
||||
{
|
||||
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
||||
|
||||
@@ -82,10 +82,11 @@ public class TeamScheduler
|
||||
var model = new CpModel();
|
||||
|
||||
// Build membership matrix: m[i,t] = 1 if student i is on team t, else 0
|
||||
// Use team.Students to support PartialTeam objects with omitted students
|
||||
var m = new int[_students.Length,_teams.Length];
|
||||
foreach (var i in _students)
|
||||
foreach (var t in _teams)
|
||||
m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0;
|
||||
m[i, t] = _teamObjects[t].Students.Any(s => s.Id == _studentObjects[i].Id) ? 1 : 0;
|
||||
|
||||
// Decision variables:
|
||||
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
|
||||
|
||||
@@ -84,6 +84,7 @@ public class TeamSchedulerSolution(
|
||||
public Team[] StudentUnassignedTeams(Student student)
|
||||
{
|
||||
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
|
||||
return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
||||
var meetingTeamIds = meetingTeams.Select(t => t.Id).ToHashSet();
|
||||
return student.Teams.Where(e => !meetingTeamIds.Contains(e.Id)).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class TeamScheduler_DecisionTree
|
||||
{
|
||||
var timeSlots = new IList<Team>[_timeSlotCount];
|
||||
for (var i = 0; i < _timeSlotCount; i++)
|
||||
timeSlots[i] = new List<Team>();
|
||||
timeSlots[i] = [];
|
||||
|
||||
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
||||
{
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||
<PackageReference Include="Google.OrTools" Version="9.7.2996" />
|
||||
<PackageReference Include="Google.OrTools" Version="9.14.6206" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||
|
||||
@@ -43,13 +43,10 @@ public class CareerField
|
||||
Id = id;
|
||||
Name = name;
|
||||
Description = description;
|
||||
DirectCareerMatches = directCareerMatches ?? Array.Empty<string>();
|
||||
PatternKeywords = patternKeywords ?? Array.Empty<string>();
|
||||
DirectCareerMatches = directCareerMatches ?? [];
|
||||
PatternKeywords = patternKeywords ?? [];
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,21 +37,6 @@ public class EventDefinition
|
||||
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
||||
|
||||
public bool OnSiteActivity { get; set; }
|
||||
//=> SemifinalistActivity != null
|
||||
// && (SemifinalistActivity.Contains("Challenge")
|
||||
// || SemifinalistActivity.Contains("Race")
|
||||
// || SemifinalistActivity.Contains("Speech")
|
||||
// || SemifinalistActivity.Contains("Test")
|
||||
// || SemifinalistActivity.Contains("Flight")
|
||||
// || Name.Contains("Leadership")
|
||||
// || Name.Contains("Forensic")
|
||||
// || Name.Contains("Flight")
|
||||
// || Name.Contains("Coding")
|
||||
// || SemifinalistActivity.Contains("Debate")
|
||||
// || SemifinalistActivity.Contains("Photography")
|
||||
// || SemifinalistActivity.Contains("Build")
|
||||
// || Name.Contains("Chapter")
|
||||
// || Name.Contains("Podcast"));
|
||||
|
||||
[StringLength(1024)]
|
||||
public string? Notes { get; set; }
|
||||
@@ -79,15 +64,12 @@ public class EventDefinition
|
||||
public string? Description { get; set; }
|
||||
public int? LevelOfEffort { get; set; }
|
||||
|
||||
public ICollection<Career> RelatedCareers { get; set; } = new List<Career>();
|
||||
public ICollection<Career> RelatedCareers { get; set; } = [];
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public string? RelatedCareersText { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
public override string ToString() => Name;
|
||||
|
||||
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
||||
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
|
||||
|
||||
@@ -0,0 +1,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})" : "")}";
|
||||
}
|
||||
|
||||
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).
|
||||
/// This is the single source of truth for month names used throughout the parser.
|
||||
/// </summary>
|
||||
public static readonly string[] MonthNames = new[]
|
||||
{
|
||||
public static readonly string[] MonthNames =
|
||||
[
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
};
|
||||
];
|
||||
|
||||
// Build month parsers dynamically from MonthNames array
|
||||
private static readonly Parser<string>[] MonthParsers = MonthNames
|
||||
|
||||
@@ -27,7 +27,7 @@ public class StudentEventRankingParser : CsvParserBase
|
||||
continue;
|
||||
|
||||
|
||||
var competitiveEvents = new List<EventDefinition>(6);
|
||||
var competitiveEvents = new List<EventDefinition>();
|
||||
|
||||
for (var i = 1; i <= 6; i++)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ public class StudentParser : CsvParserBase
|
||||
|
||||
public Student[] Parse()
|
||||
{
|
||||
var s = new List<Student>();
|
||||
var students = new List<Student>();
|
||||
|
||||
CsvReader.Read();
|
||||
CsvReader.ReadHeader();
|
||||
@@ -44,9 +44,9 @@ public class StudentParser : CsvParserBase
|
||||
RegionalId = regionalId,
|
||||
NationalId = nationalId
|
||||
};
|
||||
s.Add(student);
|
||||
students.Add(student);
|
||||
}
|
||||
|
||||
return s.ToArray();
|
||||
return students.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public static class CareerFieldDefinitions
|
||||
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
|
||||
{
|
||||
if (careers == null)
|
||||
return Array.Empty<CareerField>();
|
||||
return [];
|
||||
|
||||
var careerNames = careers
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
@@ -31,7 +31,7 @@ public static class CareerFieldDefinitions
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!careerNames.Any())
|
||||
return Array.Empty<CareerField>();
|
||||
return [];
|
||||
|
||||
var matchingFields = new HashSet<CareerField>();
|
||||
var allFields = GetAllCareerFields();
|
||||
@@ -74,15 +74,15 @@ public static class CareerFieldDefinitions
|
||||
|
||||
private static IReadOnlyList<CareerField> CreateAllCareerFields()
|
||||
{
|
||||
return new List<CareerField>
|
||||
{
|
||||
return
|
||||
[
|
||||
// 1. Aerospace & Automotive Engineering
|
||||
new CareerField(
|
||||
1,
|
||||
"Aerospace & Automotive Engineering",
|
||||
"Careers focused on designing and engineering aircraft, spacecraft, and vehicles for transportation.",
|
||||
new[] { "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" },
|
||||
new[] { "aeronautical", "aircraft", "automobile", "automotive", "race car" }
|
||||
[ "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" ],
|
||||
[ "aeronautical", "aircraft", "automobile", "automotive", "race car" ]
|
||||
),
|
||||
|
||||
// 2. Mechanical & Robotics Engineering
|
||||
@@ -90,8 +90,8 @@ public static class CareerFieldDefinitions
|
||||
2,
|
||||
"Mechanical & Robotics Engineering",
|
||||
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
|
||||
new[] { "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" },
|
||||
new[] { "mechanical", "robotics", "machine" }
|
||||
[ "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" ],
|
||||
[ "mechanical", "robotics", "machine" ]
|
||||
),
|
||||
|
||||
// 3. Electrical & Electronics Engineering
|
||||
@@ -99,8 +99,8 @@ public static class CareerFieldDefinitions
|
||||
3,
|
||||
"Electrical & Electronics Engineering",
|
||||
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
|
||||
new[] { "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" },
|
||||
new[] { "electrical", "electronic", "electrician" }
|
||||
[ "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" ],
|
||||
[ "electrical", "electronic", "electrician" ]
|
||||
),
|
||||
|
||||
// 4. Civil & Structural Engineering
|
||||
@@ -108,8 +108,8 @@ public static class CareerFieldDefinitions
|
||||
4,
|
||||
"Civil & Structural Engineering",
|
||||
"Engineering fields focused on infrastructure, buildings, bridges, and construction project management.",
|
||||
new[] { "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" },
|
||||
new[] { "civil", "construction", "structural", "contractor" }
|
||||
[ "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" ],
|
||||
[ "civil", "construction", "structural", "contractor" ]
|
||||
),
|
||||
|
||||
// 5. Environmental & Energy Engineering
|
||||
@@ -117,8 +117,8 @@ public static class CareerFieldDefinitions
|
||||
5,
|
||||
"Environmental & Energy Engineering",
|
||||
"Engineering careers focused on sustainable energy solutions, environmental protection, and chemical processes.",
|
||||
new[] { "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" },
|
||||
new[] { "chemical", "energy", "environmental", "solar" }
|
||||
[ "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" ],
|
||||
[ "chemical", "energy", "environmental", "solar" ]
|
||||
),
|
||||
|
||||
// 6. General Engineering & Quality
|
||||
@@ -126,8 +126,8 @@ public static class CareerFieldDefinitions
|
||||
6,
|
||||
"General Engineering & Quality",
|
||||
"Broad engineering roles including management, quality assurance, and standards compliance across various industries.",
|
||||
new[] { "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" },
|
||||
new[] { "engineer", "quality", "standards" }
|
||||
[ "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" ],
|
||||
[ "engineer", "quality", "standards" ]
|
||||
),
|
||||
|
||||
// 7. Architecture & Urban Planning
|
||||
@@ -135,8 +135,8 @@ public static class CareerFieldDefinitions
|
||||
7,
|
||||
"Architecture & Urban Planning",
|
||||
"Design and planning careers focused on buildings, spaces, and community development.",
|
||||
new[] { "Architect", "Community planner", "Interior designer", "Urban and regional planner" },
|
||||
new[] { "architect", "planner", "interior design", "urban" }
|
||||
[ "Architect", "Community planner", "Interior designer", "Urban and regional planner" ],
|
||||
[ "architect", "planner", "interior design", "urban" ]
|
||||
),
|
||||
|
||||
// 8. Software Development
|
||||
@@ -144,8 +144,8 @@ public static class CareerFieldDefinitions
|
||||
8,
|
||||
"Software Development",
|
||||
"Careers in creating, designing, and developing computer software applications and systems.",
|
||||
new[] { "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" },
|
||||
new[] { "programming", "programmer", "software", "developer" }
|
||||
[ "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" ],
|
||||
[ "programming", "programmer", "software", "developer" ]
|
||||
),
|
||||
|
||||
// 9. IT & Networking
|
||||
@@ -153,8 +153,8 @@ public static class CareerFieldDefinitions
|
||||
9,
|
||||
"IT & Networking",
|
||||
"Information technology careers involving computer systems, networks, technical support, and telecommunications.",
|
||||
new[] { "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" },
|
||||
new[] { "network", "computer", "technical support", "telecommunications", "IT" }
|
||||
[ "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" ],
|
||||
[ "network", "computer", "technical support", "telecommunications", "IT" ]
|
||||
),
|
||||
|
||||
// 10. Cybersecurity & Digital Forensics
|
||||
@@ -162,8 +162,8 @@ public static class CareerFieldDefinitions
|
||||
10,
|
||||
"Cybersecurity & Digital Forensics",
|
||||
"Security-focused careers protecting digital systems, investigating cybercrimes, and ensuring information security.",
|
||||
new[] { "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" },
|
||||
new[] { "cyber", "security", "forensics", "cryptography", "vulnerability" }
|
||||
[ "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" ],
|
||||
[ "cyber", "security", "forensics", "cryptography", "vulnerability" ]
|
||||
),
|
||||
|
||||
// 11. Data Science & Analytics
|
||||
@@ -171,8 +171,8 @@ public static class CareerFieldDefinitions
|
||||
11,
|
||||
"Data Science & Analytics",
|
||||
"Careers analyzing data, applying mathematical and statistical methods to solve problems and make decisions.",
|
||||
new[] { "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" },
|
||||
new[] { "data", "analyst", "actuary", "economist", "mathematician", "research" }
|
||||
[ "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" ],
|
||||
[ "data", "analyst", "actuary", "economist", "mathematician", "research" ]
|
||||
),
|
||||
|
||||
// 12. CAD, CNC & Manufacturing
|
||||
@@ -180,8 +180,8 @@ public static class CareerFieldDefinitions
|
||||
12,
|
||||
"CAD, CNC & Manufacturing",
|
||||
"Careers in computer-aided design, manufacturing processes, and production planning.",
|
||||
new[] { "CAD professional", "CNC programmer", "Manufacturing", "Production planner" },
|
||||
new[] { "CAD", "CNC", "manufacturing", "production" }
|
||||
[ "CAD professional", "CNC programmer", "Manufacturing", "Production planner" ],
|
||||
[ "CAD", "CNC", "manufacturing", "production" ]
|
||||
),
|
||||
|
||||
// 13. Industrial & Product Design
|
||||
@@ -189,8 +189,8 @@ public static class CareerFieldDefinitions
|
||||
13,
|
||||
"Industrial & Product Design",
|
||||
"Design careers creating products, commercial goods, and industrial solutions with focus on form and function.",
|
||||
new[] { "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" },
|
||||
new[] { "designer", "design", "industrial", "product", "appraiser" }
|
||||
[ "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" ],
|
||||
[ "designer", "design", "industrial", "product", "appraiser" ]
|
||||
),
|
||||
|
||||
// 14. Visual Arts & Animation
|
||||
@@ -198,8 +198,8 @@ public static class CareerFieldDefinitions
|
||||
14,
|
||||
"Visual Arts & Animation",
|
||||
"Creative careers in visual design, illustration, animation, and digital art creation.",
|
||||
new[] { "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" },
|
||||
new[] { "animator", "artist", "graphic", "illustrator", "multimedia" }
|
||||
[ "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" ],
|
||||
[ "animator", "artist", "graphic", "illustrator", "multimedia" ]
|
||||
),
|
||||
|
||||
// 15. Game Design & Interactive Media
|
||||
@@ -207,8 +207,8 @@ public static class CareerFieldDefinitions
|
||||
15,
|
||||
"Game Design & Interactive Media",
|
||||
"Careers in video game design, development, testing, and professional gaming.",
|
||||
new[] { "Game designer", "Game Play Tester", "Professional Gamer" },
|
||||
new[] { "game", "gamer", "gaming" }
|
||||
[ "Game designer", "Game Play Tester", "Professional Gamer" ],
|
||||
[ "game", "gamer", "gaming" ]
|
||||
),
|
||||
|
||||
// 16. Audio & Music Production
|
||||
@@ -216,8 +216,8 @@ public static class CareerFieldDefinitions
|
||||
16,
|
||||
"Audio & Music Production",
|
||||
"Careers in audio engineering, music composition, sound design, and broadcast technology.",
|
||||
new[] { "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" },
|
||||
new[] { "audio", "music", "broadcast", "sound" }
|
||||
[ "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" ],
|
||||
[ "audio", "music", "broadcast", "sound" ]
|
||||
),
|
||||
|
||||
// 17. Video & Film Production
|
||||
@@ -225,8 +225,8 @@ public static class CareerFieldDefinitions
|
||||
17,
|
||||
"Video & Film Production",
|
||||
"Careers in video production, filmmaking, directing, and television broadcasting.",
|
||||
new[] { "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" },
|
||||
new[] { "video", "film", "director", "television", "broadcast", "videographer" }
|
||||
[ "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" ],
|
||||
[ "video", "film", "director", "television", "broadcast", "videographer" ]
|
||||
),
|
||||
|
||||
// 18. Web & Digital Communications
|
||||
@@ -234,8 +234,8 @@ public static class CareerFieldDefinitions
|
||||
18,
|
||||
"Web & Digital Communications",
|
||||
"Careers in web design, digital communication, and instructional technology.",
|
||||
new[] { "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" },
|
||||
new[] { "web", "website", "digital", "communications", "webmaster" }
|
||||
[ "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" ],
|
||||
[ "web", "website", "digital", "communications", "webmaster" ]
|
||||
),
|
||||
|
||||
// 19. Writing & Publishing
|
||||
@@ -243,8 +243,8 @@ public static class CareerFieldDefinitions
|
||||
19,
|
||||
"Writing & Publishing",
|
||||
"Careers in writing, editing, publishing, and content creation across various media formats.",
|
||||
new[] { "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" },
|
||||
new[] { "writing", "writer", "editor", "publisher", "copy" }
|
||||
[ "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" ],
|
||||
[ "writing", "writer", "editor", "publisher", "copy" ]
|
||||
),
|
||||
|
||||
// 20. Journalism & Public Relations
|
||||
@@ -252,8 +252,8 @@ public static class CareerFieldDefinitions
|
||||
20,
|
||||
"Journalism & Public Relations",
|
||||
"Careers in news reporting, photojournalism, public relations, and communications management.",
|
||||
new[] { "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" },
|
||||
new[] { "journalism", "reporter", "photojournalist", "communications", "speaker" }
|
||||
[ "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" ],
|
||||
[ "journalism", "reporter", "photojournalist", "communications", "speaker" ]
|
||||
),
|
||||
|
||||
// 21. Forensics & Criminal Investigation
|
||||
@@ -261,8 +261,8 @@ public static class CareerFieldDefinitions
|
||||
21,
|
||||
"Forensics & Criminal Investigation",
|
||||
"Careers in criminal investigation, forensic science, and analyzing evidence for legal proceedings.",
|
||||
new[] { "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" },
|
||||
new[] { "forensic", "detective", "investigator", "crime" }
|
||||
[ "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" ],
|
||||
[ "forensic", "detective", "investigator", "crime" ]
|
||||
),
|
||||
|
||||
// 22. Healthcare & Medical Technology
|
||||
@@ -270,8 +270,8 @@ public static class CareerFieldDefinitions
|
||||
22,
|
||||
"Healthcare & Medical Technology",
|
||||
"Medical and healthcare careers providing patient care, medical technology, and health services.",
|
||||
new[] { "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" },
|
||||
new[] { "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" }
|
||||
[ "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" ],
|
||||
[ "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" ]
|
||||
),
|
||||
|
||||
// 23. Science & Research
|
||||
@@ -279,8 +279,8 @@ public static class CareerFieldDefinitions
|
||||
23,
|
||||
"Science & Research",
|
||||
"Scientific research careers across biology, physics, meteorology, and other scientific disciplines.",
|
||||
new[] { "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" },
|
||||
new[] { "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" }
|
||||
[ "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" ],
|
||||
[ "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" ]
|
||||
),
|
||||
|
||||
// 24. Education & Training
|
||||
@@ -288,8 +288,8 @@ public static class CareerFieldDefinitions
|
||||
24,
|
||||
"Education & Training",
|
||||
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
|
||||
new[] { "Educator", "Teacher/trainer", "Technology education instructor" },
|
||||
new[] { "educator", "teacher", "trainer", "education", "instructor" }
|
||||
[ "Educator", "Teacher/trainer", "Technology education instructor" ],
|
||||
[ "educator", "teacher", "trainer", "education", "instructor" ]
|
||||
),
|
||||
|
||||
// 25. Business, Legal & Government
|
||||
@@ -297,10 +297,10 @@ public static class CareerFieldDefinitions
|
||||
25,
|
||||
"Business, Legal & Government",
|
||||
"Careers in business management, legal services, government, politics, and public policy.",
|
||||
new[] { "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" },
|
||||
new[] { "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" }
|
||||
[ "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" ],
|
||||
[ "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" ]
|
||||
)
|
||||
};
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Utility;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for formatting individual student names with overlap and absent markers.
|
||||
/// </summary>
|
||||
public static class StudentNameFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for formatting student names.
|
||||
/// </summary>
|
||||
public record FormatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the student is absent. If true, adds "(absent)" suffix. Default is false.
|
||||
/// </summary>
|
||||
public bool IsAbsent { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the student has schedule overlaps. If true, adds "*" suffix. Default is false.
|
||||
/// </summary>
|
||||
public bool HasOverlap { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single student name with overlap and absent markers.
|
||||
/// </summary>
|
||||
/// <param name="student">The student to format.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>Formatted student name.</returns>
|
||||
public static string FormatStudentName(Student student, FormatOptions options)
|
||||
{
|
||||
if (student == null)
|
||||
return string.Empty;
|
||||
|
||||
var name = student.FirstName;
|
||||
|
||||
// Add overlap marker
|
||||
if (options.HasOverlap)
|
||||
{
|
||||
name += "*";
|
||||
}
|
||||
|
||||
// Add absent marker
|
||||
if (options.IsAbsent)
|
||||
{
|
||||
name += " (absent)";
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Utility;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for formatting student names for teams with various formatting options.
|
||||
/// </summary>
|
||||
public static class TeamStudentNameFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for formatting student names.
|
||||
/// </summary>
|
||||
public record FormatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Style for indicating the team captain. Default is None.
|
||||
/// </summary>
|
||||
public CaptainIndicatorStyle CaptainIndicator { get; init; } = CaptainIndicatorStyle.None;
|
||||
|
||||
/// <summary>
|
||||
/// How to order the students. Default is None (preserve original order).
|
||||
/// </summary>
|
||||
public OrderingStyle Ordering { get; init; } = OrderingStyle.None;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to add "*" suffix for students with schedule overlaps. Default is false.
|
||||
/// </summary>
|
||||
public bool MarkOverlaps { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to add "(absent)" suffix for absent students. Default is false.
|
||||
/// </summary>
|
||||
public bool MarkAbsent { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Function to determine if a student has overlaps. Required if MarkOverlaps is true.
|
||||
/// </summary>
|
||||
public Func<Student, bool>? HasOverlaps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Collection of absent students. Required if MarkAbsent is true.
|
||||
/// </summary>
|
||||
public ICollection<Student>? AbsentStudents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only show captain indicator for team events (EventFormat.Team). Default is true.
|
||||
/// </summary>
|
||||
public bool OnlyTeamEventsForCaptain { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Style for indicating the team captain.
|
||||
/// </summary>
|
||||
public enum CaptainIndicatorStyle
|
||||
{
|
||||
/// <summary>
|
||||
/// No captain indicator.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Use asterisk (*) for captain.
|
||||
/// </summary>
|
||||
Star,
|
||||
|
||||
/// <summary>
|
||||
/// Use "(Cpt)" for captain.
|
||||
/// </summary>
|
||||
Captain
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Style for ordering students.
|
||||
/// </summary>
|
||||
public enum OrderingStyle
|
||||
{
|
||||
/// <summary>
|
||||
/// Preserve original order.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Captain first, then alphabetical by first name.
|
||||
/// </summary>
|
||||
CaptainFirst,
|
||||
|
||||
/// <summary>
|
||||
/// Alphabetical by first name.
|
||||
/// </summary>
|
||||
Alphabetical,
|
||||
|
||||
/// <summary>
|
||||
/// By grade + TSA year descending (highest first).
|
||||
/// </summary>
|
||||
GradeDescending
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single student name with the specified options.
|
||||
/// </summary>
|
||||
/// <param name="student">The student to format.</param>
|
||||
/// <param name="team">The team the student belongs to.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>Formatted student name.</returns>
|
||||
public static string FormatStudentName(Student student, Team team, FormatOptions options)
|
||||
{
|
||||
if (student == null)
|
||||
return string.Empty;
|
||||
|
||||
var name = student.FirstName;
|
||||
|
||||
// Add captain indicator (before overlap/absent markers)
|
||||
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student))
|
||||
{
|
||||
var shouldShow = !options.OnlyTeamEventsForCaptain || (team.Event != null && team.Event.EventFormat == EventFormat.Team);
|
||||
if (shouldShow)
|
||||
{
|
||||
name += options.CaptainIndicator switch
|
||||
{
|
||||
CaptainIndicatorStyle.Star => "*",
|
||||
CaptainIndicatorStyle.Captain => "(Cpt)",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Convert collections/functions to booleans for StudentNameFormatter
|
||||
var hasOverlap = options.MarkOverlaps && options.HasOverlaps != null && options.HasOverlaps(student);
|
||||
var isAbsent = options.MarkAbsent && options.AbsentStudents != null && options.AbsentStudents.Contains(student);
|
||||
|
||||
// Use StudentNameFormatter for overlap/absent markers (appended after captain indicator)
|
||||
var studentNameOptions = new StudentNameFormatter.FormatOptions
|
||||
{
|
||||
HasOverlap = hasOverlap,
|
||||
IsAbsent = isAbsent
|
||||
};
|
||||
|
||||
// Get the suffix from StudentNameFormatter (overlap/absent markers)
|
||||
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
|
||||
var suffix = baseFormatted.Substring(student.FirstName.Length);
|
||||
|
||||
return name + suffix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats all students for a team as a comma-separated string.
|
||||
/// </summary>
|
||||
/// <param name="team">The team to format students for.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>Comma-separated string of formatted student names.</returns>
|
||||
public static string FormatStudentList(Team team, FormatOptions options)
|
||||
{
|
||||
if (team?.Students == null || !team.Students.Any())
|
||||
return string.Empty;
|
||||
|
||||
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||
|
||||
return string.Join(", ", students.Select(s => FormatStudentName(s, team, options)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats all students for a team as a list of strings.
|
||||
/// </summary>
|
||||
/// <param name="team">The team to format students for.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>List of formatted student names.</returns>
|
||||
public static List<string> FormatStudentListAsList(Team team, FormatOptions options)
|
||||
{
|
||||
if (team?.Students == null || !team.Students.Any())
|
||||
return [];
|
||||
|
||||
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||
|
||||
return students.Select(s => FormatStudentName(s, team, options)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats all unique students across multiple teams for an event definition.
|
||||
/// Returns a list of unique student names (by student ID).
|
||||
/// </summary>
|
||||
/// <param name="eventDefinition">The event definition.</param>
|
||||
/// <param name="teams">The teams for this event.</param>
|
||||
/// <param name="options">Formatting options.</param>
|
||||
/// <returns>List of unique formatted student names.</returns>
|
||||
public static List<string> FormatStudentListForEvent(EventDefinition eventDefinition, IEnumerable<Team> teams, FormatOptions options)
|
||||
{
|
||||
if (eventDefinition == null || teams == null)
|
||||
return [];
|
||||
|
||||
var teamsList = teams.ToList();
|
||||
if (!teamsList.Any())
|
||||
return [];
|
||||
|
||||
// Get all unique students from all teams for this event
|
||||
var allStudents = teamsList
|
||||
.SelectMany(t => t.Students)
|
||||
.DistinctBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
if (!allStudents.Any())
|
||||
return [];
|
||||
|
||||
// Determine if each student is a captain in any team
|
||||
var studentsWithCaptainInfo = allStudents.Select(s =>
|
||||
{
|
||||
var isCaptain = teamsList.Any(t => t.Captain != null && t.Captain.Equals(s));
|
||||
// Find a team this student belongs to for formatting context
|
||||
var studentTeam = teamsList.FirstOrDefault(t => t.Students.Contains(s));
|
||||
return new { Student = s, IsCaptain = isCaptain, Team = studentTeam };
|
||||
}).ToList();
|
||||
|
||||
// Apply ordering
|
||||
var orderedStudents = options.Ordering switch
|
||||
{
|
||||
OrderingStyle.CaptainFirst => studentsWithCaptainInfo
|
||||
.OrderBy(x => !x.IsCaptain)
|
||||
.ThenBy(x => x.Student.FirstName)
|
||||
.Select(x => x.Student),
|
||||
OrderingStyle.Alphabetical => studentsWithCaptainInfo
|
||||
.OrderBy(x => x.Student.FirstName)
|
||||
.Select(x => x.Student),
|
||||
OrderingStyle.GradeDescending => studentsWithCaptainInfo
|
||||
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
|
||||
.Select(x => x.Student),
|
||||
_ => studentsWithCaptainInfo.Select(x => x.Student)
|
||||
};
|
||||
|
||||
// Format names - for event-level formatting, we need to handle captain indicator specially
|
||||
// since a student might be captain in one team but not another
|
||||
return orderedStudents.Select(s =>
|
||||
{
|
||||
var studentInfo = studentsWithCaptainInfo.First(x => x.Student.Equals(s));
|
||||
// Use the student's team if available, otherwise create a minimal team for formatting context
|
||||
var team = studentInfo.Team ?? new Team { Students = [s], Event = eventDefinition };
|
||||
|
||||
// Create options that handle captain indicator for event-level
|
||||
var eventOptions = options with
|
||||
{
|
||||
CaptainIndicator = studentInfo.IsCaptain &&
|
||||
(!options.OnlyTeamEventsForCaptain || eventDefinition.EventFormat == EventFormat.Team)
|
||||
? options.CaptainIndicator
|
||||
: CaptainIndicatorStyle.None
|
||||
};
|
||||
|
||||
return FormatStudentName(s, team, eventOptions);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<Student> ApplyOrdering(IEnumerable<Student> students, Team team, OrderingStyle ordering)
|
||||
{
|
||||
return ordering switch
|
||||
{
|
||||
OrderingStyle.CaptainFirst => students
|
||||
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
|
||||
.ThenBy(s => s.FirstName),
|
||||
OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName),
|
||||
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
|
||||
_ => students
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Reflection;
|
||||
using Core.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace Data
|
||||
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
||||
public DbSet<EventOccurrence> EventOccurrences { get; set; }
|
||||
public DbSet<Career> Careers { get; set; }
|
||||
public DbSet<Note> Notes { get; set; }
|
||||
public DbSet<NoteHistory> NoteHistories { get; set; }
|
||||
|
||||
public 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");
|
||||
});
|
||||
|
||||
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")
|
||||
@@ -323,6 +400,17 @@ namespace Data.Migrations
|
||||
b.Navigation("EventDefinition");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||
{
|
||||
b.HasOne("Core.Entities.Note", "Note")
|
||||
.WithMany("NoteHistories")
|
||||
.HasForeignKey("NoteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Note");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||
{
|
||||
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||
@@ -375,6 +463,11 @@ namespace Data.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||
{
|
||||
b.Navigation("NoteHistories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||
{
|
||||
b.Navigation("EventRankings");
|
||||
|
||||
+5
-5
@@ -10,11 +10,11 @@
|
||||
<None Remove="Parsers\TestInput\2024 TN TSA State Competition Event Times.txt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.11.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
|
||||
@@ -26,7 +26,8 @@ namespace WebApp.Authentication
|
||||
public async Task<IActionResult> CookieLogin(
|
||||
[FromForm] string email,
|
||||
[FromForm] string password,
|
||||
[FromForm] bool rememberMe = false)
|
||||
[FromForm] bool rememberMe = false,
|
||||
[FromForm] string? returnUrl = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -42,7 +43,10 @@ namespace WebApp.Authentication
|
||||
ipAddress, remaining);
|
||||
|
||||
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
|
||||
return Redirect($"/login?error={errorMsg}");
|
||||
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||
? $"/login?error={errorMsg}"
|
||||
: $"/login?error={errorMsg}&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
@@ -57,7 +61,10 @@ namespace WebApp.Authentication
|
||||
"Failed login attempt for {Email} from {IpAddress}",
|
||||
email, ipAddress);
|
||||
|
||||
return Redirect("/login?error=Invalid%20email%20or%20password.");
|
||||
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||
? "/login?error=Invalid%20email%20or%20password."
|
||||
: $"/login?error=Invalid%20email%20or%20password.&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// Success - clear rate limit tracking
|
||||
@@ -89,13 +96,22 @@ namespace WebApp.Authentication
|
||||
"Successful login for {Email} ({Role}) from {IpAddress}",
|
||||
result.Email, result.Role, ipAddress);
|
||||
|
||||
// Validate return URL is local to prevent open redirect attacks
|
||||
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
|
||||
{
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
|
||||
return Redirect("/");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during login process");
|
||||
TempData["LoginError"] = "An error occurred. Please try again.";
|
||||
return Redirect("/login");
|
||||
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||
? "/login"
|
||||
: $"/login?returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
@@ -7,6 +7,8 @@
|
||||
<base href="/" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
|
||||
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/easymde.min.css" rel="stylesheet" />
|
||||
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/markdowneditor.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
@@ -17,9 +19,12 @@
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
</AppErrorBoundary>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<input type="hidden" id="emailInput" name="email" value="" />
|
||||
<input type="hidden" id="passwordInput" name="password" value="" />
|
||||
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
|
||||
<input type="hidden" id="returnUrlInput" name="returnUrl" value="@_returnUrl" />
|
||||
|
||||
<MudTextField @bind-Value="_loginModel.Email"
|
||||
Label="Email"
|
||||
@@ -75,6 +76,7 @@
|
||||
private MudInputType _passwordInput = MudInputType.Password;
|
||||
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
|
||||
private string _antiforgeryToken = string.Empty;
|
||||
private string? _returnUrl;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -86,13 +88,17 @@
|
||||
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
|
||||
}
|
||||
|
||||
// Check for error message from query parameter (set by controller on failed login)
|
||||
// Check for error message and returnUrl from query parameters
|
||||
var uri = new Uri(Navigation.Uri);
|
||||
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
||||
if (queryParams.TryGetValue("error", out var errorValue))
|
||||
{
|
||||
_errorMessage = errorValue.ToString();
|
||||
}
|
||||
if (queryParams.TryGetValue("returnUrl", out var returnUrlValue))
|
||||
{
|
||||
_returnUrl = returnUrlValue.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private class LoginModel
|
||||
@@ -141,10 +147,12 @@
|
||||
private async Task HandleFormSubmit()
|
||||
{
|
||||
// Update hidden inputs with current model values, then submit the form
|
||||
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
|
||||
await JS.InvokeVoidAsync("eval", $@"
|
||||
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
||||
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
||||
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
||||
document.getElementById('returnUrlInput').value = {returnUrlValue};
|
||||
document.getElementById('loginForm').submit();
|
||||
");
|
||||
}
|
||||
|
||||
@@ -1,79 +1 @@
|
||||
@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 Microsoft.Extensions.Logging
|
||||
@using WebApp.Authentication
|
||||
@using Core.Utility
|
||||
@inject IEventOccurrenceService EventOccurrenceService
|
||||
@inject ILogger<Index> Logger
|
||||
@inject IDialogService DialogService
|
||||
@@ -99,7 +100,7 @@
|
||||
// Load teams for all event definitions
|
||||
var teamsByEventId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
|
||||
|
||||
var items = new List<CalendarEventItem>();
|
||||
List<CalendarEventItem> items = [];
|
||||
foreach (var occ in eventOccurrences)
|
||||
{
|
||||
try
|
||||
@@ -110,9 +111,16 @@
|
||||
}
|
||||
|
||||
// Get student first names for this event definition
|
||||
var studentFirstNames = occ.EventDefinition != null
|
||||
? StudentFirstNames(occ.EventDefinition, teamsByEventId)
|
||||
: new List<string>();
|
||||
var studentFirstNames = occ.EventDefinition != null && teamsByEventId.TryGetValue(occ.EventDefinition.Id, out var teams)
|
||||
? TeamStudentNameFormatter.FormatStudentListForEvent(
|
||||
occ.EventDefinition,
|
||||
teams,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Star,
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical
|
||||
})
|
||||
: [];
|
||||
|
||||
var calendarItem = new CalendarEventItem(occ, studentFirstNames);
|
||||
items.Add(calendarItem);
|
||||
@@ -135,7 +143,7 @@
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error loading calendar events");
|
||||
_calendarItems = new List<CalendarEventItem>();
|
||||
_calendarItems = [];
|
||||
}
|
||||
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()
|
||||
{
|
||||
@@ -220,7 +200,7 @@
|
||||
|
||||
private string GetEventTooltip(CalendarEventItem item)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
List<string> parts = [];
|
||||
|
||||
if (!string.IsNullOrEmpty(item.EventDefinition?.Name))
|
||||
{
|
||||
|
||||
@@ -37,7 +37,11 @@ else
|
||||
Events are shown in blue, career fields in green. Career fields are clusters of related careers.
|
||||
Click on a node to see details. Use mouse to zoom and pan the graph.
|
||||
</MudText>
|
||||
|
||||
|
||||
<div style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<Network Id="careerMappingNetwork" Data="@_networkData" Options="@GetNetworkOptions" OnClick="HandleNetworkClick" />
|
||||
</div>
|
||||
|
||||
@if (_selectedNodeInfo != null)
|
||||
{
|
||||
<MudPaper Elevation="1" Class="pa-4 mb-4" Style="background-color: #f5f5f5;">
|
||||
@@ -61,9 +65,6 @@ else
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ else
|
||||
{
|
||||
if (!_fieldIdToCareers.ContainsKey(field.Id))
|
||||
{
|
||||
_fieldIdToCareers[field.Id] = new List<string>();
|
||||
_fieldIdToCareers[field.Id] = [];
|
||||
}
|
||||
// Add unique career names for this field
|
||||
foreach (var career in evt.RelatedCareers)
|
||||
@@ -152,8 +153,8 @@ else
|
||||
|
||||
private NetworkData GenerateNetworkData(List<EventDefinition> events)
|
||||
{
|
||||
var nodes = new List<Node>();
|
||||
var edges = new List<Edge>();
|
||||
List<Node> nodes = [];
|
||||
List<Edge> edges = [];
|
||||
|
||||
// Dictionary to track node IDs (to avoid duplicates)
|
||||
var eventNodeIds = new Dictionary<int, string>();
|
||||
@@ -283,7 +284,7 @@ else
|
||||
Title = field.Name,
|
||||
Description = field.Description,
|
||||
IsCareerField = true,
|
||||
Careers = careers?.ToList() ?? new List<string>()
|
||||
Careers = careers?.ToList() ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{
|
||||
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
|
||||
} *@
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Filled" Style="background-color: white; font-family: monospace; white-space: pre;">
|
||||
<MudChip T="string" Color="Color.Default" Variant="@AppIcons.EventChipVariant()" Style="background-color: white; font-family: monospace; white-space: pre;">
|
||||
@{
|
||||
var loeIcon = AppIcons.LevelOfEffortIcon(EventDefinition.LevelOfEffort);
|
||||
var loeColor = AppIcons.IconColors.GetValueOrDefault(loeIcon, "inherit");
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||
OnClick="PrintPage"
|
||||
Variant="Variant.Outlined">Print</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||
</div>
|
||||
</ActionButtons>
|
||||
</PageHeader>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/events"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -30,7 +31,7 @@
|
||||
<PropertyColumn Property="@(e => e.Name)" Title="Event Name" Sortable="true">
|
||||
<CellTemplate>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
<MudLink Href="@($"/events/details?id={context.Item.Id}")"
|
||||
<MudLink Href="@($"/events/details?id={context.Item.Id}&returnUrl=/events")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
@context.Item.Name
|
||||
@@ -38,7 +39,7 @@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||
TooltipText="Edit"
|
||||
Href="@($"/events/edit?id={context.Item.Id}")" />
|
||||
Href="@($"/events/edit?id={context.Item.Id}&returnUrl=/events")" />
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
||||
TooltipText="Delete"
|
||||
HoverColor="Color.Error"
|
||||
@@ -74,16 +75,32 @@
|
||||
@code {
|
||||
MudDataGrid<EventDefinition> _dataGrid = null!;
|
||||
private bool _isLoading = true;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
private async Task<GridData<EventDefinition>> ServerReload(GridState<EventDefinition> state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var query = Context.Events.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
var totalItems = await query.CountAsync();
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
|
||||
var query = Context.Events
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||
|
||||
var totalItems = await query.CountAsync(cancellationToken);
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
|
||||
|
||||
return new GridData<EventDefinition>
|
||||
{
|
||||
@@ -91,31 +108,97 @@
|
||||
Items = pagedData
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteEventDefinition(EventDefinition evt)
|
||||
{
|
||||
//_isRowBlocked = true;
|
||||
if (_isDisposed) return;
|
||||
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete Event",
|
||||
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (result == true)
|
||||
try
|
||||
{
|
||||
Context.Events.Remove(evt!);
|
||||
await Context.SaveChangesAsync();
|
||||
Snackbar.Add($"Delete event: Delete of Event {evt.Name}", Severity.Info);
|
||||
}
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete Event",
|
||||
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
// Load the event fresh from database with tracking to avoid tracking conflicts
|
||||
var eventToDelete = await Context.Events
|
||||
.FirstOrDefaultAsync(e => e.Id == evt.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (eventToDelete == null)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add("Event not found or already deleted", Severity.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Context.Events.Remove(eventToDelete);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Event {eventToDelete.Name} deleted", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting event: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using Core.Calculation
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Utility
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
@inject ClipboardService ClipboardService
|
||||
@@ -45,7 +46,13 @@
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
|
||||
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
||||
Class="ma-3"
|
||||
OnClick="Solve"
|
||||
Color="Color.Primary"
|
||||
Disabled="@_isSolving">
|
||||
Solve
|
||||
</MudButton>
|
||||
<MudTooltip Text="Copy to Clipboard">
|
||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||
</MudTooltip>
|
||||
@@ -62,11 +69,14 @@
|
||||
<MudGrid>
|
||||
<MudItem xs="12" lg="6">
|
||||
<ScheduledTeamsList TimeSlotName="@context.Name"
|
||||
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
|
||||
Teams="@context.Teams"
|
||||
ScheduledTeams="@_scheduledTeams"
|
||||
AbsentStudents="@_absentStudents"
|
||||
StudentHasOverlaps="@context.StudentHasOverlaps"
|
||||
OnToggleTeam="@ToggleRequiredTeam" />
|
||||
ExcludedStudents="@_excludedStudents"
|
||||
OnToggleTeam="@ToggleRequiredTeam"
|
||||
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" lg="6">
|
||||
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
||||
@@ -90,11 +100,13 @@
|
||||
Label="Search for absent students"
|
||||
ShowFullName="true"/>
|
||||
<MudDivider Class="my-4"/>
|
||||
<TeamToggleSelector Teams="@_teams"
|
||||
SelectedTeams="_scheduledTeams"
|
||||
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
||||
Title="Scheduled Teams"
|
||||
ShowEventAttributes="true" />
|
||||
<TeamMeetingToggleSelector Teams="@_teams"
|
||||
SelectedTeams="_scheduledTeams"
|
||||
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
||||
ExtendedTeams="_extendedTeams"
|
||||
ExtendedTeamsChanged="OnExtendedTeamsChanged"
|
||||
Title="Scheduled Teams"
|
||||
ShowEventAttributes="false" />
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@@ -111,76 +123,99 @@
|
||||
private IEnumerable<Team> _scheduledTeams = [];
|
||||
private IEnumerable<Student> _absentStudents = [];
|
||||
private IEnumerable<Team> _possibleAdditions = [];
|
||||
private IEnumerable<Team> _extendedTeams = [];
|
||||
// Key: (teamId, timeSlotIndex, studentId) - Value: true if excluded
|
||||
private Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> _excludedStudents = new();
|
||||
// Track last saved state for dirty/clean comparison
|
||||
private MeetingScheduleState? _lastSavedState;
|
||||
|
||||
private async Task OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
||||
private void OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
||||
{
|
||||
_scheduledTeams = teams;
|
||||
await SaveScheduledTeams();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task OnAbsentStudentsChanged(IEnumerable<Student> students)
|
||||
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
|
||||
{
|
||||
_absentStudents = students;
|
||||
await SaveAbsentStudents();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task OnTimeSlotCountChanged(int timeSlots)
|
||||
{
|
||||
_parameters.TimeSlots = timeSlots;
|
||||
await SaveTimeSlotCount();
|
||||
StateHasChanged();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void AddRegionals()
|
||||
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
|
||||
{
|
||||
_extendedTeams = teams;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void AddRegionals()
|
||||
{
|
||||
_scheduledTeams
|
||||
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
||||
await SaveScheduledTeams();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void AddHighLevelOfEffort()
|
||||
private void AddHighLevelOfEffort()
|
||||
{
|
||||
_scheduledTeams
|
||||
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
||||
await SaveScheduledTeams();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void RemoveIndividual()
|
||||
private void RemoveIndividual()
|
||||
{
|
||||
_scheduledTeams
|
||||
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
||||
await SaveScheduledTeams();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void RemoveLowLevelOfEffort()
|
||||
private void RemoveLowLevelOfEffort()
|
||||
{
|
||||
_scheduledTeams
|
||||
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
||||
await SaveScheduledTeams();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void Invert()
|
||||
private void Invert()
|
||||
{
|
||||
var rt = _scheduledTeams.ToArray();
|
||||
_scheduledTeams
|
||||
= _teams.Where(t => !rt.Contains(t));
|
||||
await SaveScheduledTeams();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void Reset()
|
||||
private void Reset()
|
||||
{
|
||||
_scheduledTeams = [];
|
||||
await SaveScheduledTeams();
|
||||
_extendedTeams = [];
|
||||
_excludedStudents.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async void ToggleRequiredTeam(Team unassignedTeam)
|
||||
private void ToggleRequiredTeam(Team unassignedTeam)
|
||||
{
|
||||
if (_scheduledTeams.Contains(unassignedTeam))
|
||||
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam);
|
||||
// Find the matching team from _teams to ensure reference equality with MudToggleGroup
|
||||
var matchingTeam = _teams.FirstOrDefault(t => t.Id == unassignedTeam.Id);
|
||||
if (matchingTeam == null) return;
|
||||
|
||||
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
|
||||
IEnumerable<Team> newScheduledTeams;
|
||||
|
||||
if (scheduledTeamIds.Contains(matchingTeam.Id))
|
||||
newScheduledTeams = _scheduledTeams.Where(t => t.Id != matchingTeam.Id);
|
||||
else
|
||||
{
|
||||
_scheduledTeams = _scheduledTeams.Concat(new[] { unassignedTeam });
|
||||
newScheduledTeams = _scheduledTeams.Concat([matchingTeam]);
|
||||
}
|
||||
await SaveScheduledTeams();
|
||||
|
||||
// Update state and notify component to re-render
|
||||
OnScheduledTeamsChanged(newScheduledTeams);
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -216,6 +251,7 @@
|
||||
|
||||
_teams
|
||||
= await Context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Students)
|
||||
.OrderBy(e => e.Event.Name)
|
||||
@@ -224,16 +260,34 @@
|
||||
|
||||
_students =
|
||||
await Context.Students
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Captain)
|
||||
.ThenInclude(t => t.Event)
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(t => t.Captain)
|
||||
.Include(e => e.EventRankings)
|
||||
.ThenInclude(e => e.EventDefinition)
|
||||
.ThenInclude(e => e.EventDefinition)
|
||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
||||
|
||||
// Load saved selections from localStorage
|
||||
await LoadScheduledTeams();
|
||||
await LoadAbsentStudents();
|
||||
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()
|
||||
@@ -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)
|
||||
{
|
||||
_isSolving = true;
|
||||
@@ -305,24 +472,98 @@
|
||||
|
||||
// Update parameters with absent student names
|
||||
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
|
||||
|
||||
// Update parameters with extended team event names
|
||||
_parameters.ExtendedTeams = _extendedTeams
|
||||
.Where(t => t.Event != null)
|
||||
.Select(t => t.Event!.Name)
|
||||
.ToArray();
|
||||
|
||||
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents);
|
||||
// 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();
|
||||
|
||||
// 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
|
||||
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
||||
var strategies = new[]
|
||||
{
|
||||
UnassignedScheduleStrategy[] strategies =
|
||||
[
|
||||
UnassignedScheduleStrategy.LevelOfEffort,
|
||||
UnassignedScheduleStrategy.BiggestGroup,
|
||||
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
||||
UnassignedScheduleStrategy.IndividualEvents
|
||||
};
|
||||
];
|
||||
|
||||
_possibleAdditions = strategies
|
||||
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
||||
.FirstOrDefault(result => result.Any()) ?? [];
|
||||
|
||||
// Save state to localStorage after solving completes successfully
|
||||
var currentState = MeetingScheduleState.FromCurrent(
|
||||
_scheduledTeams,
|
||||
_absentStudents,
|
||||
_parameters.TimeSlots,
|
||||
_extendedTeams,
|
||||
_excludedStudents);
|
||||
await currentState.SaveToLocalStorage(LocalStorage);
|
||||
_lastSavedState = currentState;
|
||||
|
||||
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
||||
|
||||
_isSolving = false;
|
||||
@@ -334,6 +575,78 @@
|
||||
_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()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
@@ -356,6 +669,7 @@
|
||||
|
||||
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
||||
{
|
||||
var timeSlotIndex = GetTimeSlotIndex(timeslot.Name);
|
||||
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
||||
{
|
||||
var teamName = scheduledTeam.ToString();
|
||||
@@ -366,30 +680,73 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
var studentsList = FormatStudentList(scheduledTeam, timeslot);
|
||||
var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex);
|
||||
sb.Append($"{teamName} - {studentsList}");
|
||||
}
|
||||
sb.Append(Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot)
|
||||
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot, int timeSlotIndex)
|
||||
{
|
||||
return string.Join(", ",
|
||||
team.Students
|
||||
.OrderBy(e => e == team.Captain)
|
||||
.ThenBy(e => e.FirstName)
|
||||
.Select(e => FormatStudentName(e, timeslot)));
|
||||
// Filter out excluded students for this team and time slot
|
||||
var excludedStudentIds = _excludedStudents.Keys
|
||||
.Where(k => k.teamId == team.Id && k.timeSlotIndex == timeSlotIndex && _excludedStudents[k])
|
||||
.Select(k => k.studentId)
|
||||
.ToHashSet();
|
||||
|
||||
var includedStudents = team.Students
|
||||
.Where(s => !excludedStudentIds.Contains(s.Id))
|
||||
.ToList();
|
||||
|
||||
// Create a temporary team with only included students for formatting
|
||||
var teamForFormatting = new Team
|
||||
{
|
||||
Id = team.Id,
|
||||
Event = team.Event,
|
||||
Students = includedStudents,
|
||||
Captain = team.Captain,
|
||||
Identifier = team.Identifier
|
||||
};
|
||||
|
||||
return TeamStudentNameFormatter.FormatStudentList(
|
||||
teamForFormatting,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.CaptainFirst,
|
||||
MarkOverlaps = true,
|
||||
HasOverlaps = timeslot.StudentHasOverlaps,
|
||||
MarkAbsent = true,
|
||||
AbsentStudents = _absentStudents.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
|
||||
{
|
||||
var name = student.FirstName;
|
||||
if (timeslot.StudentHasOverlaps(student))
|
||||
name += "*";
|
||||
if (_absentStudents.Contains(student))
|
||||
name += " (absent)";
|
||||
return name;
|
||||
// Find the team this student belongs to for formatting context
|
||||
var team = _teams.FirstOrDefault(t => t.Students.Contains(student));
|
||||
if (team == null)
|
||||
{
|
||||
// No team context, use StudentNameFormatter directly
|
||||
return StudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
new StudentNameFormatter.FormatOptions
|
||||
{
|
||||
HasOverlap = timeslot.StudentHasOverlaps(student),
|
||||
IsAbsent = _absentStudents.Contains(student)
|
||||
});
|
||||
}
|
||||
|
||||
return TeamStudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
MarkOverlaps = true,
|
||||
HasOverlaps = timeslot.StudentHasOverlaps,
|
||||
MarkAbsent = true,
|
||||
AbsentStudents = _absentStudents.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private void AppendUnscheduledStudents(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
||||
@@ -402,9 +759,12 @@
|
||||
|
||||
foreach (var student in timeslot.UnscheduledStudents)
|
||||
{
|
||||
var studentName = student.FirstName;
|
||||
if (_absentStudents.Contains(student))
|
||||
studentName += " (absent)";
|
||||
var studentName = StudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
new StudentNameFormatter.FormatOptions
|
||||
{
|
||||
IsAbsent = _absentStudents.Contains(student)
|
||||
});
|
||||
|
||||
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
||||
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
||||
@@ -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 WebApp.Models
|
||||
@using Core.Utility
|
||||
|
||||
<MudStack>
|
||||
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
|
||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
||||
{
|
||||
var removed = !ScheduledTeams.Contains(team);
|
||||
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
|
||||
var removed = !scheduledTeamIds.Contains(team.Id);
|
||||
|
||||
<MudLink Typo="Typo.body1"
|
||||
Class="d-flex align-center"
|
||||
Color="Color.Default"
|
||||
OnClick="@(() => OnToggleTeam.InvokeAsync(team))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
||||
Size="Size.Small"
|
||||
Class="@(removed ? "" : "d-none")">
|
||||
</MudIcon>
|
||||
@team -
|
||||
@foreach (var student in team.Students)
|
||||
{
|
||||
var overlap = StudentHasOverlaps(student);
|
||||
var isAbsent = AbsentStudents.Contains(student);
|
||||
var color = overlap ? Color.Warning : Color.Default;
|
||||
var suffix = GetStudentSuffix(overlap, isAbsent);
|
||||
|
||||
if (student != team.Students.First())
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
|
||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
||||
Size="Size.Small"
|
||||
Class="@(removed ? "" : "d-none")"
|
||||
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
|
||||
Style="cursor: pointer;">
|
||||
</MudIcon>
|
||||
@{
|
||||
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
});
|
||||
}
|
||||
<MudText Typo="Typo.body2" Color="@color">
|
||||
@student.FirstName@suffix
|
||||
</MudText>
|
||||
}
|
||||
</MudLink>
|
||||
<MudTooltip Text="@teamMembers">
|
||||
<div @onclick="@(() => OnToggleTeam.InvokeAsync(team))" style="cursor: pointer; display: inline-block;">
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
Variant="@AppIcons.TeamChipVariant()">
|
||||
@team
|
||||
</MudChip>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
@{
|
||||
var nonExcludedStudentCount = GetNonExcludedStudentCount(team);
|
||||
}
|
||||
@foreach (var student in team.Students)
|
||||
{
|
||||
var overlap = StudentHasOverlaps(student);
|
||||
var chipColor = overlap ? Color.Warning : Color.Default;
|
||||
var isExcluded = IsStudentExcluded(team.Id, TimeSlotIndex, student.Id);
|
||||
var formattedName = TeamStudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
MarkOverlaps = true,
|
||||
HasOverlaps = StudentHasOverlaps,
|
||||
MarkAbsent = true,
|
||||
AbsentStudents = AbsentStudents.ToList()
|
||||
});
|
||||
|
||||
@if (nonExcludedStudentCount > 1)
|
||||
{
|
||||
<InteractiveChip Size="Size.Small"
|
||||
Color="@chipColor"
|
||||
Variant="@AppIcons.StudentChipVariant()"
|
||||
Style="@(isExcluded ? "opacity: 0.5;" : "")">
|
||||
<ChildContent>
|
||||
<span>@formattedName</span>
|
||||
</ChildContent>
|
||||
<ControlContent>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
Variant="Variant.Text"
|
||||
OnClick="@(() => OnToggleStudentExclusion.InvokeAsync((team.Id, TimeSlotIndex, student.Id)))"
|
||||
Style="padding: 0; min-width: 16px; width: 16px; height: 16px; margin-left: 4px;" />
|
||||
</ControlContent>
|
||||
</InteractiveChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@chipColor"
|
||||
Variant="@AppIcons.StudentChipVariant()"
|
||||
Style="@(isExcluded ? "opacity: 0.5;" : "")">
|
||||
<span>@formattedName</span>
|
||||
</MudChip>
|
||||
}
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -39,6 +95,9 @@
|
||||
[Parameter]
|
||||
public string TimeSlotName { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public int TimeSlotIndex { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IEnumerable<Team> Teams { get; set; } = [];
|
||||
|
||||
@@ -51,13 +110,23 @@
|
||||
[Parameter]
|
||||
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> ExcludedStudents { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Team> OnToggleTeam { get; set; }
|
||||
|
||||
private string GetStudentSuffix(bool overlap, bool isAbsent)
|
||||
[Parameter]
|
||||
public EventCallback<(int teamId, int timeSlotIndex, int studentId)> OnToggleStudentExclusion { get; set; }
|
||||
|
||||
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
|
||||
{
|
||||
var suffix = overlap ? "*" : "";
|
||||
suffix += isAbsent ? " (absent)" : "";
|
||||
return suffix;
|
||||
var key = (teamId, timeSlotIndex, studentId);
|
||||
return ExcludedStudents.ContainsKey(key) && ExcludedStudents[key];
|
||||
}
|
||||
|
||||
private int GetNonExcludedStudentCount(Team team)
|
||||
{
|
||||
return team.Students.Count(s => !IsStudentExcluded(team.Id, TimeSlotIndex, s.Id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@using Core.Calculation
|
||||
@using Core.Utility
|
||||
@using WebApp.Models
|
||||
|
||||
@if (UnscheduledStudents.Any())
|
||||
{
|
||||
@@ -6,31 +8,52 @@
|
||||
<MudStack>
|
||||
@foreach (var student in UnscheduledStudents)
|
||||
{
|
||||
var isAbsent = AbsentStudents.Contains(student);
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.body1" HtmlTag="i">
|
||||
@student.FirstName@(isAbsent ? " (absent)" : "")
|
||||
</MudText>
|
||||
@foreach (var unassignedTeam in UnassignedTeams(student))
|
||||
{
|
||||
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
||||
var isScheduled = ScheduledTeams.Contains(unassignedTeam);
|
||||
var color = isPossibleAddition ? Color.Success : Color.Default;
|
||||
|
||||
if (unassignedTeam != UnassignedTeams(student).First())
|
||||
{
|
||||
<span>, </span>
|
||||
}
|
||||
<MudLink Typo="Typo.body2"
|
||||
Color="@color"
|
||||
OnClick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Check"
|
||||
Size="Size.Small"
|
||||
Class="@(isScheduled ? "" : "d-none")">
|
||||
</MudIcon>
|
||||
@unassignedTeam
|
||||
</MudLink>
|
||||
@{
|
||||
var formattedName = StudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
new StudentNameFormatter.FormatOptions
|
||||
{
|
||||
IsAbsent = AbsentStudents.Contains(student)
|
||||
});
|
||||
}
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||
<MudChip T="string" Size="Size.Small" Variant="@AppIcons.StudentChipVariant()" Class="font-style-italic">
|
||||
@formattedName
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
@foreach (var unassignedTeam in UnassignedTeams(student))
|
||||
{
|
||||
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
||||
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
|
||||
var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id);
|
||||
var chipColor = isPossibleAddition ? Color.Success : Color.Default;
|
||||
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||
unassignedTeam,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
});
|
||||
|
||||
<MudTooltip Text="@teamMembers">
|
||||
<div @onclick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))" style="cursor: pointer; display: inline-block;">
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@chipColor"
|
||||
Variant="@AppIcons.TeamChipVariant()">
|
||||
@if (isScheduled)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Style="margin-right: 4px;" />
|
||||
}
|
||||
@unassignedTeam
|
||||
</MudChip>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -140,11 +140,13 @@ else
|
||||
.OrderBy(e => e.Event.Name)
|
||||
.ToArray();
|
||||
|
||||
var events = await Context.Events.ToArrayAsync();
|
||||
var events = await Context.Events
|
||||
.AsNoTracking()
|
||||
.ToArrayAsync();
|
||||
var remainingEvents =
|
||||
events
|
||||
.Where(e => _eventStudentRankings.All(est => est.Event.Id != e.Id))
|
||||
.Select(e => new EventStudentRankings { Event = e, StudentRanking = Array.Empty<Tuple<Student, int>>() })
|
||||
.Select(e => new EventStudentRankings { Event = e, StudentRanking = [] })
|
||||
.OrderBy(e => e.Event.Name)
|
||||
.ToArray();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/students"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@@ -30,7 +31,7 @@
|
||||
<PropertyColumn Property="@(e => e.LastName)" Title="Name" Sortable="true">
|
||||
<CellTemplate>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 0.25rem;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.Wrap">
|
||||
<MudLink Href="@($"/students/details?id={context.Item.Id}&returnUrl=/students")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
@@ -40,7 +41,7 @@
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
|
||||
}
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||
TooltipText="Edit"
|
||||
@@ -68,18 +69,34 @@
|
||||
@code {
|
||||
MudDataGrid<Student> _dataGrid = null!;
|
||||
private bool _isLoading = true;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
var query =
|
||||
Context.Students.OrderBy(e => e.LastName)
|
||||
Context.Students
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.LastName)
|
||||
.Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||
|
||||
var totalItems = await query.CountAsync();
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
|
||||
var totalItems = await query.CountAsync(cancellationToken);
|
||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
|
||||
|
||||
return new GridData<Student>
|
||||
{
|
||||
@@ -87,31 +104,97 @@
|
||||
Items = pagedData
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
return new GridData<Student> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteStudent(Student student)
|
||||
{
|
||||
//_isRowBlocked = true;
|
||||
if (_isDisposed) return;
|
||||
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete student",
|
||||
(MarkupString)$"Are you sure want to delete <b>{student.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (result == true)
|
||||
try
|
||||
{
|
||||
Context.Students.Remove(student!);
|
||||
await Context.SaveChangesAsync();
|
||||
Snackbar.Add($"Delete event: Delete of Student {student.Name}", Severity.Info);
|
||||
}
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete student",
|
||||
(MarkupString)$"Are you sure want to delete <b>{student.Name}</b>? This cannot be undone.",
|
||||
yesText:"Yes",
|
||||
noText:"Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
// Load the student fresh from database with tracking to avoid tracking conflicts
|
||||
var studentToDelete = await Context.Students
|
||||
.FirstOrDefaultAsync(s => s.Id == student.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (studentToDelete == null)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add("Student not found or already deleted", Severity.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Context.Students.Remove(studentToDelete);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Student {studentToDelete.Name} deleted", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting student: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Validation
|
||||
@using Core.Utility
|
||||
@inject AppDbContext Context
|
||||
@inject WebApp.LocalStorageService LocalStorage
|
||||
@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 != null).OrderBy(t => t.Event.Name);
|
||||
}
|
||||
<div class="d-flex flex-wrap" style="gap: 0.25rem;">
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
@foreach (var team in teamsToDisplay)
|
||||
{
|
||||
var isCaptain = team.Captain != null && team.Captain.Equals(context.Item.Student);
|
||||
var teamMembers = string.Join(", ", team.Students.Select(s => s.FirstName));
|
||||
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
});
|
||||
|
||||
<MudTooltip Text="@teamMembers">
|
||||
<MudChip Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
Href="@($"/teams/edit?id={team.Id}")"
|
||||
Variant="@AppIcons.TeamChipVariant()"
|
||||
Href="@($"/teams/edit?id={team.Id}&returnUrl=/students/teams")"
|
||||
Style="cursor: pointer;">
|
||||
@team
|
||||
@if (isCaptain && team.Event.EventFormat != EventFormat.Individual)
|
||||
@@ -98,7 +105,7 @@
|
||||
</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</MudStack>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</Columns>
|
||||
@@ -172,14 +179,38 @@
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
// Load all students with their teams
|
||||
// Load teams with their students separately to avoid circular reference
|
||||
var teamsWithStudents = await Context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Students)
|
||||
.Include(t => t.Event)
|
||||
.Include(t => t.Captain)
|
||||
.ToListAsync();
|
||||
|
||||
// Create a dictionary for quick lookup of team students
|
||||
var teamStudentsDict = teamsWithStudents.ToDictionary(t => t.Id, t => t.Students.ToList());
|
||||
|
||||
// Load all students with their teams (without loading team students to avoid circular reference)
|
||||
var students = await Context.Students
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Teams)
|
||||
.ThenInclude(t => t.Event)
|
||||
.Include(s => s.Teams)
|
||||
.ThenInclude(t => t.Captain)
|
||||
.ToListAsync();
|
||||
|
||||
// Populate Students for each team from the separately loaded teams
|
||||
foreach (var student in students)
|
||||
{
|
||||
foreach (var team in student.Teams)
|
||||
{
|
||||
if (teamStudentsDict.TryGetValue(team.Id, out var teamStudents))
|
||||
{
|
||||
team.Students = teamStudents;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to only students with teams
|
||||
var studentTeams = students
|
||||
.Where(s => s.Teams.Any(t => t?.Event != null && (!_showRegionalOnly || t.Event.RegionalEvent)))
|
||||
@@ -270,7 +301,7 @@
|
||||
Student = student,
|
||||
Events = student.Teams?.Where(t => t?.Event != null)
|
||||
.Select(t => t.Event)
|
||||
.ToList() ?? new List<EventDefinition>()
|
||||
.ToList() ?? []
|
||||
};
|
||||
|
||||
return ValidationService.ValidateStudentStatistics(stats, ValidationContext.StudentRegistration);
|
||||
|
||||
@@ -153,82 +153,87 @@
|
||||
.Concat(context.Events)
|
||||
.Distinct();
|
||||
}
|
||||
@foreach (var e in
|
||||
allStudentEvents
|
||||
.OrderBy(e =>
|
||||
context.Student.EventRankings
|
||||
.Find(ser => ser.EventDefinition == e)?.Rank ?? 10))
|
||||
{
|
||||
var eventRank = context.Student.EventRankings.Find(er => er.EventDefinition == e)?.Rank;
|
||||
var isAssigned = context.Events.Contains(e);
|
||||
|
||||
var color = AppIcons.RankedEventColor(eventRank ?? 0);
|
||||
var style = string.Empty;
|
||||
|
||||
if (isAssigned)
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
@foreach (var e in
|
||||
allStudentEvents
|
||||
.OrderBy(e =>
|
||||
context.Student.EventRankings
|
||||
.Find(ser => ser.EventDefinition == e)?.Rank ?? 10))
|
||||
{
|
||||
style += "border-color:black; border-width:thin;";
|
||||
if (eventRank.HasValue)
|
||||
{
|
||||
style += $"background:{color};";
|
||||
if (eventRank == 1)
|
||||
style += $"color:black";
|
||||
}
|
||||
else
|
||||
style += $"background:{Colors.Gray.Lighten3};";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (eventRank.HasValue)
|
||||
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
|
||||
}
|
||||
var eventRank = context.Student.EventRankings.Find(er => er.EventDefinition == e)?.Rank;
|
||||
var isAssigned = context.Events.Contains(e);
|
||||
|
||||
<MudPaper Class="d-inline-flex align-center pa-2 mx-3 my-1 border-solid" Style="@(style)">
|
||||
@e.ShortName
|
||||
@AppIcons.EventAttributes(e)
|
||||
@{
|
||||
var isIncluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Include) == null;
|
||||
var isExcluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Exclude) == null;
|
||||
}
|
||||
@if (isIncluded)
|
||||
var color = AppIcons.RankedEventColor(eventRank ?? 0);
|
||||
var style = "border-style: solid;";
|
||||
|
||||
if (isAssigned)
|
||||
{
|
||||
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
style += "border-color:black; border-width:thin;";
|
||||
if (eventRank.HasValue)
|
||||
{
|
||||
style += $"background:{color};";
|
||||
if (eventRank == 1)
|
||||
style += $"color:black";
|
||||
}
|
||||
else
|
||||
style += $"background:{Colors.Gray.Lighten3};";
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
if (eventRank.HasValue)
|
||||
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
|
||||
}
|
||||
|
||||
@if (isExcluded)
|
||||
{
|
||||
<MudTooltip Text="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
var isIncluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Include) == null;
|
||||
var isExcluded = _assignmentRequirements
|
||||
.Find(ar =>
|
||||
ar.EventDefinition == e
|
||||
&& ar.Student == context.Student
|
||||
&& ar.Requirement == Requirement.Exclude) == null;
|
||||
|
||||
<InteractiveChip WrapperClass="my-1" Style="@style" Variant="@AppIcons.EventChipVariant()">
|
||||
<ChildContent>
|
||||
@e.ShortName
|
||||
@AppIcons.EventAttributes(e)
|
||||
</ChildContent>
|
||||
<ControlContent>
|
||||
@if (isIncluded)
|
||||
{
|
||||
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@if (isExcluded)
|
||||
{
|
||||
<MudTooltip Text="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</ControlContent>
|
||||
</InteractiveChip>
|
||||
}
|
||||
</MudStack>
|
||||
<MudDivider Style="border-width:3px" />
|
||||
</td></MudTr>
|
||||
</ChildRowContent>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
@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
|
||||
<div class="d-flex flex-wrap" style="gap: 0.25rem;">
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
@foreach (var student in
|
||||
Team.Students
|
||||
.OrderBy(e =>
|
||||
@@ -17,7 +17,7 @@
|
||||
<MudTooltip Text="@rankLabel">
|
||||
<MudChip T="string"
|
||||
Size="Size.Medium"
|
||||
Variant="Variant.Outlined"
|
||||
Variant="@AppIcons.StudentChipVariant()"
|
||||
Class="mx-1 my-1">
|
||||
@if (eventRank.HasValue)
|
||||
{
|
||||
@@ -31,7 +31,7 @@
|
||||
</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@using WebApp.Models
|
||||
@using Core.Utility
|
||||
|
||||
@if (Title != null)
|
||||
{
|
||||
@@ -14,9 +15,15 @@
|
||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.Event.Name))
|
||||
{
|
||||
<MudToggleItem Value="@team" Style="font-size: .75rem;">
|
||||
<MudTooltip Text="@team.StudentsFirstNames">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<MudText Class="ellipsis">@team.ToString()</MudText>
|
||||
<MudTooltip Text="@TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain,
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
})">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px; width: 100%;">
|
||||
<span class="ellipsis" style="flex: 1; min-width: 0;">@team.ToString()</span>
|
||||
@if (ShowEventAttributes)
|
||||
{
|
||||
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
|
||||
|
||||
@@ -71,7 +71,9 @@
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Students)
|
||||
.FirstOrDefaultAsync(m => m.Id == Id);
|
||||
_students = await Context.Students.ToListAsync();
|
||||
_students = await Context.Students
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
_selectedStudents = Team?.Students.ToList();
|
||||
|
||||
if (Team is null)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Utility
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
|
||||
@@ -35,7 +36,12 @@ else
|
||||
@if (team.Event.EventFormat == EventFormat.Team)
|
||||
{
|
||||
<span style="font-weight: normal; font-size: 0.9em;">
|
||||
(Team: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName)))
|
||||
(Team: @TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending
|
||||
}))
|
||||
</span>
|
||||
}
|
||||
</MudText>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Utility
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
|
||||
@@ -57,7 +58,12 @@ else
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudText Class="d-flex py-1" Typo="Typo.h6">
|
||||
Team Members: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName))
|
||||
Team Members: @TeamStudentNameFormatter.FormatStudentList(
|
||||
team,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending
|
||||
})
|
||||
</MudText>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using WebApp.Models
|
||||
@page "/teams"
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@inject AppDbContext Context
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -36,7 +37,7 @@
|
||||
<TemplateColumn Title="Event" Sortable="true" SortBy="@(t => t.Event.Name)">
|
||||
<CellTemplate>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
<MudLink Href="@($"/teams/details?id={context.Item.Id}")"
|
||||
<MudLink Href="@($"/teams/details?id={context.Item.Id}&returnUrl=/teams")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
@context.Item.ToString()
|
||||
@@ -44,7 +45,7 @@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||
TooltipText="Edit"
|
||||
Href="@($"/teams/edit?id={context.Item.Id}")" />
|
||||
Href="@($"/teams/edit?id={context.Item.Id}&returnUrl=/teams")" />
|
||||
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
||||
TooltipText="Delete"
|
||||
HoverColor="Color.Error"
|
||||
@@ -74,31 +75,60 @@
|
||||
MudDataGrid<Team> _dataGrid = null!;
|
||||
private bool _isLoading = true;
|
||||
private bool _showRegionalOnly = false;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private bool _isDisposed = false;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
private async Task ToggleRegionalFilter()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
_showRegionalOnly = !_showRegionalOnly;
|
||||
if (_dataGrid != null)
|
||||
if (_dataGrid != null && !_isDisposed)
|
||||
{
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GridData<Team>> ServerReload(GridState<Team> state)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return new GridData<Team> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
IQueryable<Team> query
|
||||
= Context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Captain)
|
||||
.Include(e => e.Students)
|
||||
.ThenInclude(e => e.EventRankings);
|
||||
|
||||
@@ -113,7 +143,7 @@
|
||||
query = query.Where(state.FilterDefinitions);
|
||||
|
||||
// Load all data first
|
||||
var allTeams = await query.ToArrayAsync();
|
||||
var allTeams = await query.ToArrayAsync(cancellationToken);
|
||||
|
||||
// Always sort by EventFormat FIRST to separate group/individual teams
|
||||
// Sort in memory to ensure this ordering is maintained regardless of user sorts
|
||||
@@ -173,45 +203,117 @@
|
||||
Items = pagedData
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, return empty result
|
||||
return new GridData<Team> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, return empty result
|
||||
return new GridData<Team> { TotalItems = 0, Items = [] };
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTeam(Team team)
|
||||
{
|
||||
//_isRowBlocked = true;
|
||||
if (_isDisposed) return;
|
||||
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete team",
|
||||
(MarkupString)$"Are you sure want to delete <b>{team}</b>? This cannot be undone.",
|
||||
yesText: "Yes",
|
||||
noText: "Cancel");
|
||||
|
||||
if (result == true)
|
||||
try
|
||||
{
|
||||
// If deleting a numbered team (1 or 2), clear the identifier of the remaining team
|
||||
if (team.Identifier == "1" || team.Identifier == "2")
|
||||
{
|
||||
var remainingTeam = await Context.Teams
|
||||
.Include(t => t.Event)
|
||||
.FirstOrDefaultAsync(t => t.Event.Id == team.Event.Id && t.Id != team.Id);
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
if (remainingTeam != null)
|
||||
var result = await DialogService
|
||||
.ShowMessageBox("Delete team",
|
||||
(MarkupString)$"Are you sure want to delete <b>{team}</b>? This cannot be undone.",
|
||||
yesText: "Yes",
|
||||
noText: "Cancel");
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
// Load the team fresh from database with tracking to avoid tracking conflicts
|
||||
var teamToDelete = await Context.Teams
|
||||
.Include(t => t.Event)
|
||||
.Include(t => t.Students)
|
||||
.FirstOrDefaultAsync(t => t.Id == team.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (teamToDelete == null)
|
||||
{
|
||||
remainingTeam.Identifier = null;
|
||||
Context.Teams.Update(remainingTeam);
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add("Team not found or already deleted", Severity.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If deleting a numbered team (1 or 2), clear the identifier of the remaining team
|
||||
if (teamToDelete.Identifier == "1" || teamToDelete.Identifier == "2")
|
||||
{
|
||||
var remainingTeam = await Context.Teams
|
||||
.Include(t => t.Event)
|
||||
.FirstOrDefaultAsync(t => t.Event.Id == teamToDelete.Event.Id && t.Id != teamToDelete.Id, cancellationToken);
|
||||
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (remainingTeam != null)
|
||||
{
|
||||
remainingTeam.Identifier = null;
|
||||
Context.Teams.Update(remainingTeam);
|
||||
}
|
||||
}
|
||||
|
||||
Context.Teams.Remove(teamToDelete);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Team {teamToDelete} deleted", Severity.Info);
|
||||
}
|
||||
}
|
||||
|
||||
Context.Teams.Remove(team!);
|
||||
await Context.SaveChangesAsync();
|
||||
Snackbar.Add($"Delete event: Delete of Team {team}", Severity.Info);
|
||||
if (!_isDisposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Component was disposed, ignore
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// JS connection lost, ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
Snackbar.Add($"Error deleting team: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
await _dataGrid.ReloadServerData();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@using WebApp.Components.Shared.Components
|
||||
@using Core.Utility
|
||||
@inject IConfiguration Configuration
|
||||
@inject AppDbContext Context
|
||||
|
||||
@@ -56,7 +57,13 @@ else
|
||||
.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue;
|
||||
|
||||
<MudTd Class="@(EventRankClass(rank))">
|
||||
@student.Name @if(context?.Captain == student) {<span> (Cpt)</span>}
|
||||
@TeamStudentNameFormatter.FormatStudentName(
|
||||
student,
|
||||
context,
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain
|
||||
})
|
||||
</MudTd>
|
||||
}
|
||||
else
|
||||
@@ -191,7 +198,12 @@ else
|
||||
<span>❔</span>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>@string.Join(", ", team.Students.Where(e => e != context).Select(e => e.FirstName))</MudTd>
|
||||
<MudTd>@TeamStudentNameFormatter.FormatStudentList(
|
||||
new Team { Students = team.Students.Where(e => e != context).ToList(), Event = team.Event },
|
||||
new TeamStudentNameFormatter.FormatOptions
|
||||
{
|
||||
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||
})</MudTd>
|
||||
<MudTd></MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@@ -218,6 +230,7 @@ else
|
||||
{
|
||||
_teams
|
||||
= await Context.Teams
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Event)
|
||||
.Include(e => e.Students)
|
||||
.OrderByEventFormatFirst()
|
||||
@@ -228,6 +241,7 @@ else
|
||||
_maxTeamSize = _teams.Max(t => t.Students.Count);
|
||||
_students =
|
||||
await Context.Students
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Captain)
|
||||
.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)">
|
||||
<NotAuthorized>
|
||||
@{
|
||||
navigationManager.NavigateTo("/login", true);
|
||||
var currentUri = navigationManager.Uri;
|
||||
var baseUri = navigationManager.BaseUri;
|
||||
var relativePath = currentUri.Replace(baseUri, "").TrimStart('/');
|
||||
var returnUrl = string.IsNullOrEmpty(relativePath) ? "/" : "/" + relativePath;
|
||||
var encodedReturnUrl = Uri.EscapeDataString(returnUrl);
|
||||
navigationManager.NavigateTo($"/login?returnUrl={encodedReturnUrl}", true);
|
||||
}
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
|
||||
</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">
|
||||
<MudDivider Class="my-2"/>
|
||||
<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 Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@@ -28,3 +28,5 @@
|
||||
@using Core.Models
|
||||
@using Data
|
||||
@using VisNetwork.Blazor
|
||||
@using PSC.Blazor.Components.MarkdownEditor
|
||||
@using WebApp.Services
|
||||
@@ -9,10 +9,12 @@ namespace WebApp;
|
||||
public sealed class LocalStorageService
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly ILogger<LocalStorageService> _logger;
|
||||
|
||||
public LocalStorageService(IJSRuntime jsRuntime)
|
||||
public LocalStorageService(IJSRuntime jsRuntime, ILogger<LocalStorageService> logger)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,7 +49,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to save boolean to localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to save boolean to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +85,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to save integer to localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to save integer to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +102,14 @@ public sealed class LocalStorageService
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
var array = JsonSerializer.Deserialize<int[]>(json);
|
||||
return array ?? Array.Empty<int>();
|
||||
return array ?? [];
|
||||
}
|
||||
return Array.Empty<int>();
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to load integer array from localStorage [{key}]: {ex.Message}");
|
||||
return Array.Empty<int>();
|
||||
_logger.LogWarning(ex, "Failed to load integer array from localStorage [{Key}]", key);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +127,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to save integer array to localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to save integer array to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +143,50 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to remove from localStorage [{key}]: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to remove from localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a JSON-serialized object from localStorage.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <param name="key">The storage key.</param>
|
||||
/// <returns>The deserialized object or default value if not found.</returns>
|
||||
public async Task<T?> GetJsonAsync<T>(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load JSON from localStorage [{Key}]", key);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a JSON-serialized object in localStorage.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to serialize.</typeparam>
|
||||
/// <param name="key">The storage key.</param>
|
||||
/// <param name="value">The object to store.</param>
|
||||
public async Task SetJsonAsync<T>(string key, T value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save JSON to localStorage [{Key}]", key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +201,7 @@ public sealed class LocalStorageService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to clear localStorage: {ex.Message}");
|
||||
_logger.LogWarning(ex, "Failed to clear localStorage");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,15 +33,14 @@ namespace WebApp.Logging
|
||||
LogEventLevel.Information,
|
||||
null, // No exception at Info level
|
||||
messageTemplate,
|
||||
new[]
|
||||
{
|
||||
[
|
||||
new LogEventProperty("OriginalMessage",
|
||||
new ScalarValue(logEvent.MessageTemplate.Render(logEvent.Properties))),
|
||||
new LogEventProperty("SourceContext",
|
||||
logEvent.Properties.GetValueOrDefault("SourceContext") ?? new ScalarValue("Unknown")),
|
||||
new LogEventProperty("RequestPath",
|
||||
logEvent.Properties.GetValueOrDefault("RequestPath") ?? new ScalarValue("Unknown"))
|
||||
});
|
||||
]);
|
||||
|
||||
_wrappedSink.Emit(rewrittenEvent);
|
||||
}
|
||||
|
||||
@@ -154,5 +154,20 @@ namespace WebApp.Models
|
||||
};
|
||||
return $"{number}<sup>{suffix}</sup>";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the standard chip variant for student chips (Outlined).
|
||||
/// </summary>
|
||||
public static Variant StudentChipVariant() => Variant.Outlined;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the standard chip variant for event chips (Filled).
|
||||
/// </summary>
|
||||
public static Variant EventChipVariant() => Variant.Filled;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the standard chip variant for team chips (Filled).
|
||||
/// </summary>
|
||||
public static Variant TeamChipVariant() => Variant.Filled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class CalendarEventItem : CalendarItem
|
||||
EventDefinition = occurrence.EventDefinition;
|
||||
// Set base class properties that the calendar component uses
|
||||
StudentFirstNames = studentFirstNames?.ToList() ?? [];
|
||||
Text = occurrence.EventDefinition?.ShortName;
|
||||
Text = occurrence.EventDefinition?.ShortName ?? string.Empty;
|
||||
Start = occurrence.StartTime;
|
||||
End = occurrence.EndTime ?? occurrence.StartTime.AddHours(1);
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
|
||||
});
|
||||
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
||||
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
|
||||
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
|
||||
|
||||
// State container for maintaining state per user connection (Blazor Server)
|
||||
builder.Services.AddScoped<StateContainer>();
|
||||
|
||||
@@ -40,6 +40,7 @@ public class EventDefinitionService
|
||||
|
||||
// Get all existing careers from database (case-insensitive lookup)
|
||||
var existingCareers = await _context.Careers
|
||||
.AsNoTracking()
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
.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>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
@@ -29,10 +29,12 @@
|
||||
</PackageReference>
|
||||
<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="MudBlazor" Version="8.14.0" />
|
||||
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<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>
|
||||
|
||||
@@ -128,4 +128,105 @@
|
||||
body {
|
||||
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