From 6bc4c2e7f2dc1b142e4e0fa9a940204c80458dd4 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Mon, 19 Jan 2026 22:02:59 -0500 Subject: [PATCH] Add TeamMeetingHistory entity, service, and UI components for meeting history management This commit introduces the TeamMeetingHistory entity, including its configuration and database migrations. A new ITeamMeetingHistoryService interface and its implementation, TeamMeetingHistoryService, are added to handle CRUD operations for meeting histories. Additionally, UI components such as History.razor, MeetingHistoryDetailDialog, and SaveMeetingHistoryDialog are created to facilitate viewing and saving meeting histories. The integration of INoteNamingService enhances note management for meeting records, improving overall functionality and user experience in the application. --- Core/Entities/TeamMeetingHistory.cs | 16 + Core/Services/INoteNamingService.cs | 38 ++ Core/Services/NoteNamingService.cs | 50 ++ Data/AppDbContext.cs | 1 + .../TeamMeetingHistoryConfiguration.cs | 29 + ...20024048_AddTeamMeetingHistory.Designer.cs | 567 ++++++++++++++++++ .../20260120024048_AddTeamMeetingHistory.cs | 104 ++++ Data/Migrations/AppDbContextModelSnapshot.cs | 76 +++ .../Features/MeetingSchedule/History.razor | 282 +++++++++ .../Features/MeetingSchedule/Index.razor | 43 ++ .../MeetingHistoryDetailDialog.razor | 298 +++++++++ .../SaveMeetingHistoryDialog.razor | 317 ++++++++++ WebApp/Components/Pages/NoteEditDialog.razor | 4 +- WebApp/Components/Pages/Notes.razor | 6 +- .../Shared/Components/InteractiveChip.razor | 4 +- .../Shared/Components/PageNoteDialog.razor | 4 +- WebApp/Program.cs | 2 + WebApp/Services/INotesService.cs | 2 +- WebApp/Services/ITeamMeetingHistoryService.cs | 50 ++ WebApp/Services/NotesService.cs | 20 +- WebApp/Services/TeamMeetingHistoryService.cs | 205 +++++++ 21 files changed, 2102 insertions(+), 16 deletions(-) create mode 100644 Core/Entities/TeamMeetingHistory.cs create mode 100644 Core/Services/INoteNamingService.cs create mode 100644 Core/Services/NoteNamingService.cs create mode 100644 Data/Configurations/TeamMeetingHistoryConfiguration.cs create mode 100644 Data/Migrations/20260120024048_AddTeamMeetingHistory.Designer.cs create mode 100644 Data/Migrations/20260120024048_AddTeamMeetingHistory.cs create mode 100644 WebApp/Components/Features/MeetingSchedule/History.razor create mode 100644 WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor create mode 100644 WebApp/Components/Features/MeetingSchedule/SaveMeetingHistoryDialog.razor create mode 100644 WebApp/Services/ITeamMeetingHistoryService.cs create mode 100644 WebApp/Services/TeamMeetingHistoryService.cs diff --git a/Core/Entities/TeamMeetingHistory.cs b/Core/Entities/TeamMeetingHistory.cs new file mode 100644 index 0000000..af7df3a --- /dev/null +++ b/Core/Entities/TeamMeetingHistory.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Core.Entities; + +public class TeamMeetingHistory +{ + public int Id { get; set; } + + [Required] + [Display(Name = "Meeting Date")] + public DateTime MeetingDate { get; set; } + + // Navigation properties + public List Teams { get; set; } = []; + public List Students { get; set; } = []; +} diff --git a/Core/Services/INoteNamingService.cs b/Core/Services/INoteNamingService.cs new file mode 100644 index 0000000..ff37eb9 --- /dev/null +++ b/Core/Services/INoteNamingService.cs @@ -0,0 +1,38 @@ +namespace Core.Services; + +/// +/// Service for managing note naming conventions throughout the system. +/// Centralizes the logic for generating note titles for meeting notes and page notes. +/// +public interface INoteNamingService +{ + /// + /// Gets the title for a meeting note based on the meeting date. + /// Format: "#Meeting Notes MM/dd/yyyy" + /// + /// The date of the meeting + /// The formatted meeting note title + string GetMeetingNoteTitle(DateTime meetingDate); + + /// + /// Gets the title for a page note based on the page identifier. + /// Format: "#{pageIdentifier}" + /// + /// The page identifier (e.g., "students", "teams") + /// The formatted page note title + string GetPageNoteTitle(string pageIdentifier); + + /// + /// Checks if a note title represents a page note (starts with "#"). + /// + /// The note title to check + /// True if the note is a page note, false otherwise + bool IsPageNote(string noteTitle); + + /// + /// Checks if a note title represents a meeting note (starts with "#Meeting Notes"). + /// + /// The note title to check + /// True if the note is a meeting note, false otherwise + bool IsMeetingNote(string noteTitle); +} diff --git a/Core/Services/NoteNamingService.cs b/Core/Services/NoteNamingService.cs new file mode 100644 index 0000000..7ca3ca5 --- /dev/null +++ b/Core/Services/NoteNamingService.cs @@ -0,0 +1,50 @@ +namespace Core.Services; + +/// +/// Implementation of INoteNamingService that provides note naming conventions. +/// Uses "#" as the prefix for page notes and meeting notes. +/// +public class NoteNamingService : INoteNamingService +{ + private const string PageNotePrefix = "#"; + private const string MeetingNotePrefix = "#Meeting Notes"; + + /// + public string GetMeetingNoteTitle(DateTime meetingDate) + { + return $"{MeetingNotePrefix} {meetingDate:MM/dd/yyyy}"; + } + + /// + public string GetPageNoteTitle(string pageIdentifier) + { + if (string.IsNullOrWhiteSpace(pageIdentifier)) + { + throw new ArgumentException("Page identifier cannot be null or empty", nameof(pageIdentifier)); + } + + return $"{PageNotePrefix}{pageIdentifier}"; + } + + /// + public bool IsPageNote(string noteTitle) + { + if (string.IsNullOrWhiteSpace(noteTitle)) + { + return false; + } + + return noteTitle.StartsWith(PageNotePrefix, StringComparison.Ordinal); + } + + /// + public bool IsMeetingNote(string noteTitle) + { + if (string.IsNullOrWhiteSpace(noteTitle)) + { + return false; + } + + return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal); + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0148c7d..49f191d 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -14,6 +14,7 @@ namespace Data public DbSet Careers { get; set; } public DbSet Notes { get; set; } public DbSet NoteHistories { get; set; } + public DbSet TeamMeetingHistories { get; set; } public AppDbContext() { diff --git a/Data/Configurations/TeamMeetingHistoryConfiguration.cs b/Data/Configurations/TeamMeetingHistoryConfiguration.cs new file mode 100644 index 0000000..e9ce9c2 --- /dev/null +++ b/Data/Configurations/TeamMeetingHistoryConfiguration.cs @@ -0,0 +1,29 @@ +using Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Data.Configurations +{ + public class TeamMeetingHistoryConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(tmh => tmh.Id); + + // Indexes + builder.HasIndex(tmh => tmh.MeetingDate); + + // Constraints + builder.Property(tmh => tmh.MeetingDate) + .IsRequired(); + + builder.HasMany(tmh => tmh.Teams) + .WithMany() + .UsingEntity(j => j.ToTable("TeamMeetingHistoryTeams")); + + builder.HasMany(tmh => tmh.Students) + .WithMany() + .UsingEntity(j => j.ToTable("TeamMeetingHistoryStudents")); + } + } +} diff --git a/Data/Migrations/20260120024048_AddTeamMeetingHistory.Designer.cs b/Data/Migrations/20260120024048_AddTeamMeetingHistory.Designer.cs new file mode 100644 index 0000000..0332f9a --- /dev/null +++ b/Data/Migrations/20260120024048_AddTeamMeetingHistory.Designer.cs @@ -0,0 +1,567 @@ +// +using System; +using Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260120024048_AddTeamMeetingHistory")] + partial class AddTeamMeetingHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("CareerEventDefinition", b => + { + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("RelatedCareersId") + .HasColumnType("INTEGER"); + + b.HasKey("EventDefinitionId", "RelatedCareersId"); + + b.HasIndex("RelatedCareersId"); + + b.ToTable("EventDefinitionCareers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.Career", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Careers"); + }); + + modelBuilder.Entity("Core.Entities.EventDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterEligibilityCountRegionals") + .HasColumnType("INTEGER"); + + b.Property("ChapterEligibilityCountState") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Documentation") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Eligibility") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EventFormat") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LevelOfEffort") + .HasColumnType("INTEGER"); + + b.Property("MaxTeamSize") + .HasColumnType("INTEGER"); + + b.Property("MinTeamSize") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("OnSiteActivity") + .HasColumnType("INTEGER"); + + b.Property("Presubmission") + .HasColumnType("INTEGER"); + + b.Property("SemifinalistActivity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ShortName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SpecialEventType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPinned") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("IsPinned"); + + b.HasIndex("Title"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChangeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("ModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("NoteId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Grade") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NationalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OfficerRole") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RegionalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StateId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("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("StudentId") + .HasColumnType("INTEGER"); + + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CaptainId") + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CaptainId"); + + b.HasIndex("EventId"); + + b.HasIndex("EventId", "Identifier"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MeetingDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MeetingDate"); + + b.ToTable("TeamMeetingHistories"); + }); + + modelBuilder.Entity("StudentTeam", b => + { + b.Property("StudentsId") + .HasColumnType("INTEGER"); + + b.Property("TeamsId") + .HasColumnType("INTEGER"); + + b.HasKey("StudentsId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamStudents", (string)null); + }); + + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.Property("StudentsId") + .HasColumnType("INTEGER"); + + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.HasKey("StudentsId", "TeamMeetingHistoryId"); + + b.HasIndex("TeamMeetingHistoryId"); + + b.ToTable("TeamMeetingHistoryStudents", (string)null); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.Property("TeamsId") + .HasColumnType("INTEGER"); + + b.HasKey("TeamMeetingHistoryId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamMeetingHistoryTeams", (string)null); + }); + + modelBuilder.Entity("CareerEventDefinition", b => + { + b.HasOne("Core.Entities.EventDefinition", null) + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Career", null) + .WithMany() + .HasForeignKey("RelatedCareersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.EventOccurrence", b => + { + b.HasOne("Core.Entities.EventDefinition", "EventDefinition") + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EventDefinition"); + }); + + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.HasOne("Core.Entities.Note", "Note") + .WithMany("NoteHistories") + .HasForeignKey("NoteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Note"); + }); + + modelBuilder.Entity("Core.Entities.StudentEventRanking", b => + { + b.HasOne("Core.Entities.EventDefinition", "EventDefinition") + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Student", "Student") + .WithMany("EventRankings") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventDefinition"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Core.Entities.Team", b => + { + b.HasOne("Core.Entities.Student", "Captain") + .WithMany() + .HasForeignKey("CaptainId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.EventDefinition", "Event") + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Captain"); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("StudentTeam", b => + { + b.HasOne("Core.Entities.Student", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.Student", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.Note", b => + { + b.Navigation("NoteHistories"); + }); + + modelBuilder.Entity("Core.Entities.Student", b => + { + b.Navigation("EventRankings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260120024048_AddTeamMeetingHistory.cs b/Data/Migrations/20260120024048_AddTeamMeetingHistory.cs new file mode 100644 index 0000000..f03e29d --- /dev/null +++ b/Data/Migrations/20260120024048_AddTeamMeetingHistory.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// + public partial class AddTeamMeetingHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TeamMeetingHistories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MeetingDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMeetingHistories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TeamMeetingHistoryStudents", + columns: table => new + { + StudentsId = table.Column(type: "INTEGER", nullable: false), + TeamMeetingHistoryId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMeetingHistoryStudents", x => new { x.StudentsId, x.TeamMeetingHistoryId }); + table.ForeignKey( + name: "FK_TeamMeetingHistoryStudents_Students_StudentsId", + column: x => x.StudentsId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TeamMeetingHistoryStudents_TeamMeetingHistories_TeamMeetingHistoryId", + column: x => x.TeamMeetingHistoryId, + principalTable: "TeamMeetingHistories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TeamMeetingHistoryTeams", + columns: table => new + { + TeamMeetingHistoryId = table.Column(type: "INTEGER", nullable: false), + TeamsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMeetingHistoryTeams", x => new { x.TeamMeetingHistoryId, x.TeamsId }); + table.ForeignKey( + name: "FK_TeamMeetingHistoryTeams_TeamMeetingHistories_TeamMeetingHistoryId", + column: x => x.TeamMeetingHistoryId, + principalTable: "TeamMeetingHistories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TeamMeetingHistoryTeams_Teams_TeamsId", + column: x => x.TeamsId, + principalTable: "Teams", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TeamMeetingHistories_MeetingDate", + table: "TeamMeetingHistories", + column: "MeetingDate"); + + migrationBuilder.CreateIndex( + name: "IX_TeamMeetingHistoryStudents_TeamMeetingHistoryId", + table: "TeamMeetingHistoryStudents", + column: "TeamMeetingHistoryId"); + + migrationBuilder.CreateIndex( + name: "IX_TeamMeetingHistoryTeams_TeamsId", + table: "TeamMeetingHistoryTeams", + column: "TeamsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TeamMeetingHistoryStudents"); + + migrationBuilder.DropTable( + name: "TeamMeetingHistoryTeams"); + + migrationBuilder.DropTable( + name: "TeamMeetingHistories"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 2adc966..4d4704b 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -370,6 +370,22 @@ namespace Data.Migrations b.ToTable("Teams"); }); + modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MeetingDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MeetingDate"); + + b.ToTable("TeamMeetingHistories"); + }); + modelBuilder.Entity("StudentTeam", b => { b.Property("StudentsId") @@ -385,6 +401,36 @@ namespace Data.Migrations b.ToTable("TeamStudents", (string)null); }); + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.Property("StudentsId") + .HasColumnType("INTEGER"); + + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.HasKey("StudentsId", "TeamMeetingHistoryId"); + + b.HasIndex("TeamMeetingHistoryId"); + + b.ToTable("TeamMeetingHistoryStudents", (string)null); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.Property("TeamsId") + .HasColumnType("INTEGER"); + + b.HasKey("TeamMeetingHistoryId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamMeetingHistoryTeams", (string)null); + }); + modelBuilder.Entity("CareerEventDefinition", b => { b.HasOne("Core.Entities.EventDefinition", null) @@ -473,6 +519,36 @@ namespace Data.Migrations .IsRequired(); }); + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.Student", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Core.Entities.Note", b => { b.Navigation("NoteHistories"); diff --git a/WebApp/Components/Features/MeetingSchedule/History.razor b/WebApp/Components/Features/MeetingSchedule/History.razor new file mode 100644 index 0000000..bafde1c --- /dev/null +++ b/WebApp/Components/Features/MeetingSchedule/History.razor @@ -0,0 +1,282 @@ +@page "/meeting-schedule/history" +@attribute [Authorize] +@using Core.Entities +@using Microsoft.EntityFrameworkCore +@using WebApp.Components.Shared.Components +@using WebApp.Services +@inject ITeamMeetingHistoryService TeamMeetingHistoryService +@inject AppDbContext Context +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IConfiguration Configuration +@implements IAsyncDisposable + + + + + Back to Schedule + + + + + + @if (_isLoading) + { + + } + else + { + + @if (_meetingHistories.Any()) + { + + + + + @foreach (var team in _allTeams) + { + + } + + + + + Date + + + @{ + var teamIndex = 0; + } + @foreach (var team in _allTeams) + { + var isEven = teamIndex % 2 == 0; + var bgColor = isEven ? "background-color: var(--mud-palette-background-grey);" : ""; + var headerStyle = $"padding: 4px 2px; border-right: 1px solid var(--mud-palette-divider); {bgColor}"; + + + @team.ToString() + + + teamIndex++; + } + + + + + @context.MeetingDate.ToString("MM/dd/yy") + + + @{ + var rowTeamIndex = 0; + } + @foreach (var team in _allTeams) + { + var met = TeamMetOnDate(context, team); + var isEven = rowTeamIndex % 2 == 0; + var bgColor = isEven ? "background-color: var(--mud-palette-background-grey);" : ""; + var cellStyle = $"padding: 4px 2px; border-right: 1px solid var(--mud-palette-divider); {bgColor}"; + + @if (met) + { + × + } + + rowTeamIndex++; + } + + + + Times Met + + @{ + var footerTeamIndex = 0; + } + @foreach (var team in _allTeams) + { + var timesMet = GetTimesMetForTeam(team); + var isEven = footerTeamIndex % 2 == 0; + var bgColor = isEven ? "background-color: var(--mud-palette-background-grey);" : ""; + var footerCellStyle = $"padding: 4px 2px; border-right: 1px solid var(--mud-palette-divider); {bgColor}"; + + @timesMet + + footerTeamIndex++; + } + + + + } + else + { + No meeting history found. Save a meeting schedule to get started. + } + + } + + +@code { + private List _meetingHistories = []; + private List _allTeams = []; + private Dictionary _timesMetDict = new(); + private bool _isLoading = true; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + if (_isDisposed) return; + + try + { + _isLoading = true; + StateHasChanged(); + + // Load all teams for columns + _allTeams = await Context.Teams + .AsNoTracking() + .Include(t => t.Event) + .OrderBy(t => t.Event.Name) + .ThenBy(t => t.Identifier) + .ToListAsync(); + + // Load meeting histories + await RefreshMeetingHistories(); + + // Calculate times met + CalculateTimesMet(); + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task RefreshMeetingHistories() + { + _meetingHistories = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync()).ToList(); + + // Extract all unique teams from meeting histories and merge with all teams + var teamsInHistory = _meetingHistories + .SelectMany(mh => mh.Teams) + .DistinctBy(t => t.Id) + .ToList(); + + // Merge with all teams, ensuring all teams are included + var allTeamIds = _allTeams.Select(t => t.Id).ToHashSet(); + var newTeams = teamsInHistory.Where(t => !allTeamIds.Contains(t.Id)).ToList(); + _allTeams = _allTeams.Concat(newTeams).OrderBy(t => t.Event?.Name ?? "").ThenBy(t => t.Identifier).ToList(); + } + + private void CalculateTimesMet() + { + _timesMetDict.Clear(); + + foreach (var team in _allTeams) + { + _timesMetDict[team.Id] = 0; + } + + foreach (var history in _meetingHistories) + { + var teamIds = history.Teams.Select(t => t.Id).ToHashSet(); + foreach (var teamId in teamIds) + { + if (_timesMetDict.ContainsKey(teamId)) + { + _timesMetDict[teamId]++; + } + } + } + } + + private int GetTimesMetForTeam(Team team) + { + return _timesMetDict.GetValueOrDefault(team.Id, 0); + } + + private bool TeamMetOnDate(TeamMeetingHistory history, Team team) + { + return history.Teams.Any(t => t.Id == team.Id); + } + + private async Task ViewMeetingDetails(TeamMeetingHistory history) + { + var parameters = new DialogParameters + { + ["MeetingHistoryId"] = history.Id + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Meeting Details", parameters, options); + var result = await dialog.Result; + + if (!result.Canceled) + { + // Refresh data if meeting was updated or deleted + await RefreshMeetingHistories(); + CalculateTimesMet(); + if (!_isDisposed) + { + StateHasChanged(); + } + } + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor index a80ae52..1838376 100644 --- a/WebApp/Components/Features/MeetingSchedule/Index.razor +++ b/WebApp/Components/Features/MeetingSchedule/Index.razor @@ -4,14 +4,34 @@ @using Core.Calculation @using Microsoft.EntityFrameworkCore @using WebApp.Components.Shared.Components +@using WebApp.Components.Features.MeetingSchedule @using Core.Utility @inject IConfiguration Configuration @inject AppDbContext Context @inject ClipboardService ClipboardService @inject LocalStorageService LocalStorage +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + View History + + + + + Save as History + + @@ -671,6 +691,29 @@ } } + private async Task OpenSaveHistoryDialog() + { + var parameters = new DialogParameters + { + ["ScheduledTeams"] = _scheduledTeams, + ["AbsentStudents"] = _absentStudents, + ["AllTeams"] = _teams, + ["AllStudents"] = _students + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Save Meeting History", parameters, options); + var result = await dialog.Result; + + // Note: Success message is already shown in the dialog, no need to show another here + } + private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot) { var timeSlotIndex = GetTimeSlotIndex(timeslot.Name); diff --git a/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor b/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor new file mode 100644 index 0000000..d127d3d --- /dev/null +++ b/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor @@ -0,0 +1,298 @@ +@namespace WebApp.Components.Features.MeetingSchedule +@using Core.Entities +@using Core.Services +@using Core.Utility +@using WebApp.Services +@using WebApp.Components.Shared.Components +@using WebApp.Models +@inject ITeamMeetingHistoryService TeamMeetingHistoryService +@inject INotesService NotesService +@inject INoteNamingService NoteNamingService +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@implements IAsyncDisposable + + + + @if (_isLoading) + { + + } + else if (_meetingHistory == null) + { + Meeting history not found. + } + else + { + + Meeting Details + + + + + Date: @_meetingHistory.MeetingDate.ToString("MM/dd/yyyy") + + + + + + + Teams That Met (@_meetingHistory.Teams.Count) + + + @foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString())) + { + @team.ToString() + } + + + + + + Students (@GetAllStudentsFromTeams().Count) + + + @{ + var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet(); + var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName); + } + @foreach (var student in allStudents) + { + var isPresent = presentStudentIds.Contains(student.Id); + + @student.FirstNameLastName + + } + + + + @if (_meetingNote != null) + { + + Meeting Notes + + @_meetingNote.Title + @if (!string.IsNullOrWhiteSpace(_meetingNote.Content)) + { + + @((MarkupString)MarkdownHelper.ToHtml(_meetingNote.Content)) + + } + + Edit Note + + + } + + } + + + @if (_meetingHistory != null) + { + + Delete + + + Close + } + else + { + + Close + } + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public int MeetingHistoryId { get; set; } + + private TeamMeetingHistory? _meetingHistory; + private Note? _meetingNote; + private bool _isLoading = true; + private bool _isDeleting = false; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + private bool IsActionDisabled => _isLoading || _isDeleting; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadMeetingHistory(); + } + + private async Task LoadMeetingHistory() + { + if (_isDisposed) return; + + try + { + _meetingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId); + + // Load note by title if meeting history exists + if (_meetingHistory != null) + { + _meetingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(_meetingHistory.MeetingDate); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task ConfirmDelete() + { + if (_isDisposed || _isDeleting || _meetingHistory == null) return; + + var result = await DialogService.ShowMessageBox( + "Confirm Delete", + $"Are you sure you want to delete the meeting history for {_meetingHistory.MeetingDate:MM/dd/yyyy}? This action cannot be undone.", + yesText: "Delete", + cancelText: "Cancel"); + + if (result == true) + { + await DeleteMeetingHistory(); + } + } + + private async Task DeleteMeetingHistory() + { + if (_isDisposed || _isDeleting || _meetingHistory == null) return; + + try + { + _isDeleting = true; + StateHasChanged(); + + await TeamMeetingHistoryService.DeleteMeetingHistoryAsync(MeetingHistoryId); + + if (!_isDisposed) + { + Snackbar.Add("Meeting history deleted successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error deleting meeting history: {ex.Message}", Severity.Error); + _isDeleting = false; + StateHasChanged(); + } + } + } + + private async Task ViewNote() + { + if (_meetingNote == null) return; + + var parameters = new DialogParameters + { + ["NoteId"] = _meetingNote.Id + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Meeting Notes", parameters, options); + await dialog.Result; + + // Refresh meeting history to get updated note + await LoadMeetingHistory(); + } + + private List GetAllStudentsFromTeams() + { + if (_meetingHistory == null) + return []; + + var allStudents = new List(); + var studentIds = new HashSet(); + + foreach (var team in _meetingHistory.Teams) + { + foreach (var student in team.Students) + { + if (!studentIds.Contains(student.Id)) + { + studentIds.Add(student.Id); + allStudents.Add(student); + } + } + } + + return allStudents; + } + + private void Close() + { + if (_isDisposed) return; + MudDialog.Close(); + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Features/MeetingSchedule/SaveMeetingHistoryDialog.razor b/WebApp/Components/Features/MeetingSchedule/SaveMeetingHistoryDialog.razor new file mode 100644 index 0000000..8a1fb3d --- /dev/null +++ b/WebApp/Components/Features/MeetingSchedule/SaveMeetingHistoryDialog.razor @@ -0,0 +1,317 @@ +@namespace WebApp.Components.Features.MeetingSchedule +@using Core.Entities +@using Core.Calculation +@using Core.Services +@using WebApp.Services +@using WebApp.Components.Shared.Components +@using WebApp.Models +@inject ITeamMeetingHistoryService TeamMeetingHistoryService +@inject INotesService NotesService +@inject INoteNamingService NoteNamingService +@inject ISnackbar Snackbar +@implements IAsyncDisposable + + + + @if (_isLoading) + { + + } + else + { + + Save Meeting History + + + + + + Teams That Met + + + @foreach (var team in AllTeams.OrderByEventFormatFirst().ThenBy(e => e.ToString())) + { + + @team.ToString() + + } + + + + + + Students Present + + + @foreach (var student in AllStudents.OrderBy(s => s.FirstName)) + { + + @student.FirstNameLastName + + } + + + + + + + + + + + + + + + } + + + Cancel + + @if (_isSaving) + { + + Saving... + } + else + { + Save + } + + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public IEnumerable ScheduledTeams { get; set; } = []; + + [Parameter] + public IEnumerable AbsentStudents { get; set; } = []; + + [Parameter] + public IEnumerable AllTeams { get; set; } = []; + + [Parameter] + public IEnumerable AllStudents { get; set; } = []; + + private DateTime? _meetingDate = DateTime.Today; + private List _selectedTeams = []; + private List _selectedStudents = []; + private string _noteTitle = ""; + private string _noteContent = ""; + private bool _isLoading = true; + private bool _isSaving = false; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadInitialData(); + } + + private async Task LoadInitialData() + { + if (_isDisposed) return; + + try + { + // Initialize selected teams from scheduled teams + _selectedTeams = ScheduledTeams.ToList(); + + // Initialize selected students (all students except absent ones) + var absentStudentIds = AbsentStudents.Select(s => s.Id).ToHashSet(); + _selectedStudents = AllStudents.Where(s => !absentStudentIds.Contains(s.Id)).ToList(); + + // Generate default note title if meeting date is set + if (_meetingDate.HasValue) + { + _noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading data: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private bool IsSaveDisabled => _isSaving || _isLoading; + + protected override async Task OnParametersSetAsync() + { + if (_meetingDate.HasValue && string.IsNullOrEmpty(_noteTitle)) + { + _noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value); + } + await base.OnParametersSetAsync(); + } + + private async Task OnMeetingDateChanged(DateTime? date) + { + _meetingDate = date; + if (date.HasValue) + { + _noteTitle = NoteNamingService.GetMeetingNoteTitle(date.Value); + } + await InvokeAsync(StateHasChanged); + } + + private void OnTeamsChanged(IEnumerable teams) + { + _selectedTeams = teams.ToList(); + StateHasChanged(); + } + + private void OnStudentsChanged(IEnumerable students) + { + _selectedStudents = students.ToList(); + StateHasChanged(); + } + + private async Task Save() + { + if (_isDisposed || _isSaving) return; + + if (!_meetingDate.HasValue) + { + Snackbar.Add("Please select a meeting date", Severity.Warning); + return; + } + + try + { + _isSaving = true; + StateHasChanged(); + + // Create or update note if note content is provided + Note? note = null; + if (!string.IsNullOrWhiteSpace(_noteContent)) + { + // Use the naming service to get the meeting note title + var noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value); + + // Check if note already exists + var existingNote = await NotesService.GetNotesAsync(includeDeleted: false); + note = existingNote.FirstOrDefault(n => n.Title == noteTitle); + + if (note != null) + { + // Update existing note + note.Content = _noteContent; + note = await NotesService.UpdateNoteAsync(note); + } + else + { + // Create new note + note = new Note + { + Title = noteTitle, + Content = _noteContent + }; + note = await NotesService.CreateNoteAsync(note); + } + } + + // Create meeting history + var meetingHistory = new TeamMeetingHistory + { + MeetingDate = _meetingDate.Value, + Teams = _selectedTeams, + Students = _selectedStudents + }; + + await TeamMeetingHistoryService.CreateMeetingHistoryAsync(meetingHistory); + + if (!_isDisposed) + { + Snackbar.Add($"Meeting history saved for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error saving meeting history: {ex.Message}", Severity.Error); + _isSaving = false; + StateHasChanged(); + } + } + } + + private void Cancel() + { + if (_isDisposed) return; + MudDialog.Cancel(); + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Pages/NoteEditDialog.razor b/WebApp/Components/Pages/NoteEditDialog.razor index 7b1ea86..2178fef 100644 --- a/WebApp/Components/Pages/NoteEditDialog.razor +++ b/WebApp/Components/Pages/NoteEditDialog.razor @@ -1,8 +1,10 @@ @using Core.Entities +@using Core.Services @using WebApp.Services @using PSC.Blazor.Components.MarkdownEditor @using MudBlazor @inject INotesService NotesService +@inject INoteNamingService NoteNamingService @inject ISnackbar Snackbar @inject IDialogService DialogService @inject MarkdownTablePasteService MarkdownTablePasteService @@ -45,7 +47,7 @@ private Note _note = null!; private bool _pasteMarkdownInitialized = false; - private bool IsPageNote => Note?.Title?.StartsWith("@") ?? false; + private bool IsPageNote => Note?.Title != null && NoteNamingService.IsPageNote(Note.Title); protected override void OnInitialized() { diff --git a/WebApp/Components/Pages/Notes.razor b/WebApp/Components/Pages/Notes.razor index 3d6ba05..341eb76 100644 --- a/WebApp/Components/Pages/Notes.razor +++ b/WebApp/Components/Pages/Notes.razor @@ -2,9 +2,11 @@ @attribute [Authorize] @implements IAsyncDisposable @using Core.Entities +@using Core.Services @using WebApp.Services @using WebApp.Components.Shared.Components @inject INotesService NotesService +@inject INoteNamingService NoteNamingService @inject IDialogService DialogService @inject ISnackbar Snackbar @inject NavigationManager NavigationManager @@ -67,7 +69,7 @@ @GetNoteHeaderText(note, isExpanded) - @if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded) + @if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded) { + @ontouchstart="@HandleTouchStart"> (sp => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // State container for maintaining state per user connection (Blazor Server) diff --git a/WebApp/Services/INotesService.cs b/WebApp/Services/INotesService.cs index 8834ed4..1020fca 100644 --- a/WebApp/Services/INotesService.cs +++ b/WebApp/Services/INotesService.cs @@ -19,7 +19,7 @@ public interface INotesService /// Gets a page-specific note by page identifier. /// /// The page identifier (e.g., "Teams", "Registration") - /// The note with title "@{pageIdentifier}" or null if not found + /// The note with title "#{pageIdentifier}" or null if not found Task GetPageNoteAsync(string pageIdentifier); /// diff --git a/WebApp/Services/ITeamMeetingHistoryService.cs b/WebApp/Services/ITeamMeetingHistoryService.cs new file mode 100644 index 0000000..3d43044 --- /dev/null +++ b/WebApp/Services/ITeamMeetingHistoryService.cs @@ -0,0 +1,50 @@ +using Core.Entities; + +namespace WebApp.Services; + +public interface ITeamMeetingHistoryService +{ + /// + /// Gets all meeting histories within an optional date range. + /// + /// Optional start date filter + /// Optional end date filter + Task> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null); + + /// + /// Gets a specific meeting history by ID with teams and students included. + /// + Task GetMeetingHistoryAsync(int id); + + /// + /// Creates a new meeting history record. + /// + Task CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory); + + /// + /// Updates an existing meeting history record. + /// + Task UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory); + + /// + /// Deletes a meeting history record. + /// + Task DeleteMeetingHistoryAsync(int id); + + /// + /// Gets which teams met on a specific date. + /// + Task> GetTeamsForDateAsync(DateTime date); + + /// + /// Gets which students were present on a specific date. + /// + Task> GetStudentsForDateAsync(DateTime date); + + /// + /// Gets the meeting note for a specific meeting date by looking it up by title. + /// + /// The date of the meeting + /// The note if found, null otherwise + Task GetMeetingNoteAsync(DateTime meetingDate); +} diff --git a/WebApp/Services/NotesService.cs b/WebApp/Services/NotesService.cs index 0adbff5..6c5d844 100644 --- a/WebApp/Services/NotesService.cs +++ b/WebApp/Services/NotesService.cs @@ -1,4 +1,5 @@ using Core.Entities; +using Core.Services; using Data; using Microsoft.EntityFrameworkCore; using System.Security.Claims; @@ -10,15 +11,18 @@ public class NotesService : INotesService private readonly AppDbContext _context; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; + private readonly INoteNamingService _noteNamingService; public NotesService( AppDbContext context, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger, + INoteNamingService noteNamingService) { _context = context; _httpContextAccessor = httpContextAccessor; _logger = logger; + _noteNamingService = noteNamingService; } private string? GetCurrentUserEmail() @@ -42,7 +46,7 @@ public class NotesService : INotesService } return await query - .OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1) + .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1) .ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated .ToListAsync(); } @@ -56,7 +60,7 @@ public class NotesService : INotesService public async Task GetPageNoteAsync(string pageIdentifier) { - var pageNoteTitle = $"@{pageIdentifier}"; + var pageNoteTitle = _noteNamingService.GetPageNoteTitle(pageIdentifier); return await _context.Notes .AsNoTracking() .Where(n => n.Title == pageNoteTitle && !n.IsDeleted) @@ -184,7 +188,7 @@ public class NotesService : INotesService { return await _context.Notes .AsNoTracking() - .Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted) + .Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted) .OrderByDescending(n => n.UpdatedAt) .Take(3) .ToListAsync(); @@ -201,7 +205,7 @@ public class NotesService : INotesService } // Prevent pinning page notes - if (note.Title.StartsWith("@")) + if (_noteNamingService.IsPageNote(note.Title)) { throw new InvalidOperationException("Page notes cannot be pinned."); } @@ -213,13 +217,13 @@ public class NotesService : INotesService if (!note.IsPinned) { var pinnedCount = await _context.Notes - .CountAsync(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted); + .CountAsync(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted); if (pinnedCount >= 3) { // Unpin the oldest pinned note var oldestPinned = await _context.Notes - .Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted) + .Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted) .OrderBy(n => n.UpdatedAt) .FirstOrDefaultAsync(); @@ -250,7 +254,7 @@ public class NotesService : INotesService return await _context.Notes .AsNoTracking() .Where(n => n.IsDeleted) - .OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1) + .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1) .ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated .ToListAsync(); } diff --git a/WebApp/Services/TeamMeetingHistoryService.cs b/WebApp/Services/TeamMeetingHistoryService.cs new file mode 100644 index 0000000..704afe7 --- /dev/null +++ b/WebApp/Services/TeamMeetingHistoryService.cs @@ -0,0 +1,205 @@ +using Core.Entities; +using Core.Services; +using Data; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace WebApp.Services; + +public class TeamMeetingHistoryService : ITeamMeetingHistoryService +{ + private readonly AppDbContext _context; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + private readonly INotesService _notesService; + private readonly INoteNamingService _noteNamingService; + + public TeamMeetingHistoryService( + AppDbContext context, + IHttpContextAccessor httpContextAccessor, + ILogger logger, + INotesService notesService, + INoteNamingService noteNamingService) + { + _context = context; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + _notesService = notesService; + _noteNamingService = noteNamingService; + } + + private string? GetCurrentUserEmail() + { + var user = _httpContextAccessor.HttpContext?.User; + if (user == null) + return null; + + return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name; + } + + public async Task> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null) + { + var query = _context.TeamMeetingHistories + .AsNoTracking() + .Include(tmh => tmh.Teams) + .ThenInclude(t => t.Event) + .Include(tmh => tmh.Students) + .AsQueryable(); + + if (startDate.HasValue) + { + var start = startDate.Value.Date; + query = query.Where(tmh => tmh.MeetingDate >= start); + } + + if (endDate.HasValue) + { + var end = endDate.Value.Date.AddDays(1); // Include the entire end date + query = query.Where(tmh => tmh.MeetingDate < end); + } + + return await query + .OrderByDescending(tmh => tmh.MeetingDate) + .ToListAsync(); + } + + public async Task GetMeetingHistoryAsync(int id) + { + return await _context.TeamMeetingHistories + .Include(tmh => tmh.Teams) + .ThenInclude(t => t.Event) + .Include(tmh => tmh.Teams) + .ThenInclude(t => t.Students) + .Include(tmh => tmh.Students) + .FirstOrDefaultAsync(tmh => tmh.Id == id); + } + + public async Task CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory) + { + // Create a new meeting history entity to avoid tracking conflicts + var newMeetingHistory = new TeamMeetingHistory + { + MeetingDate = meetingHistory.MeetingDate.Date // Normalize to date only + }; + + // Attach teams by loading them from the database to avoid tracking conflicts + var teamIds = meetingHistory.Teams.Select(t => t.Id).ToList(); + var teams = await _context.Teams + .Where(t => teamIds.Contains(t.Id)) + .ToListAsync(); + + // Attach students by loading them from the database to avoid tracking conflicts + var studentIds = meetingHistory.Students.Select(s => s.Id).ToList(); + var students = await _context.Students + .Where(s => studentIds.Contains(s.Id)) + .ToListAsync(); + + // Attach teams and students to the new meeting history + newMeetingHistory.Teams = teams; + newMeetingHistory.Students = students; + + _context.TeamMeetingHistories.Add(newMeetingHistory); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Meeting history created: {MeetingHistoryId} for date {MeetingDate}", + newMeetingHistory.Id, newMeetingHistory.MeetingDate); + + return newMeetingHistory; + } + + public async Task UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory) + { + var existingHistory = await _context.TeamMeetingHistories + .Include(tmh => tmh.Teams) + .Include(tmh => tmh.Students) + .FirstOrDefaultAsync(tmh => tmh.Id == meetingHistory.Id); + + if (existingHistory == null) + { + throw new InvalidOperationException($"Meeting history with ID {meetingHistory.Id} not found."); + } + + // Update properties + existingHistory.MeetingDate = meetingHistory.MeetingDate.Date; // Normalize to date only + + // Update teams + existingHistory.Teams.Clear(); + foreach (var team in meetingHistory.Teams) + { + var existingTeam = await _context.Teams.FindAsync(team.Id); + if (existingTeam != null) + { + existingHistory.Teams.Add(existingTeam); + } + } + + // Update students + existingHistory.Students.Clear(); + foreach (var student in meetingHistory.Students) + { + var existingStudent = await _context.Students.FindAsync(student.Id); + if (existingStudent != null) + { + existingHistory.Students.Add(existingStudent); + } + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Meeting history updated: {MeetingHistoryId} by {User}", + meetingHistory.Id, GetCurrentUserEmail()); + + return existingHistory; + } + + public async Task DeleteMeetingHistoryAsync(int id) + { + var meetingHistory = await _context.TeamMeetingHistories + .FirstOrDefaultAsync(tmh => tmh.Id == id); + + if (meetingHistory == null) + { + throw new InvalidOperationException($"Meeting history with ID {id} not found."); + } + + _context.TeamMeetingHistories.Remove(meetingHistory); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Meeting history deleted: {MeetingHistoryId}", id); + } + + /// + /// Gets the meeting note for a specific meeting date by looking it up by title. + /// + /// The date of the meeting + /// The note if found, null otherwise + public async Task GetMeetingNoteAsync(DateTime meetingDate) + { + var noteTitle = _noteNamingService.GetMeetingNoteTitle(meetingDate); + var notes = await _notesService.GetNotesAsync(includeDeleted: false); + return notes.FirstOrDefault(n => n.Title == noteTitle); + } + + public async Task> GetTeamsForDateAsync(DateTime date) + { + var normalizedDate = date.Date; + return await _context.TeamMeetingHistories + .AsNoTracking() + .Where(tmh => tmh.MeetingDate == normalizedDate) + .SelectMany(tmh => tmh.Teams) + .Include(t => t.Event) + .Distinct() + .ToListAsync(); + } + + public async Task> GetStudentsForDateAsync(DateTime date) + { + var normalizedDate = date.Date; + return await _context.TeamMeetingHistories + .AsNoTracking() + .Where(tmh => tmh.MeetingDate == normalizedDate) + .SelectMany(tmh => tmh.Students) + .Distinct() + .ToListAsync(); + } +}