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.
This commit is contained in:
2026-01-19 22:02:59 -05:00
parent 9ed9c93540
commit 6bc4c2e7f2
21 changed files with 2102 additions and 16 deletions
+16
View File
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Core.Entities;
public class TeamMeetingHistory
{
public int Id { get; set; }
[Required]
[Display(Name = "Meeting Date")]
public DateTime MeetingDate { get; set; }
// Navigation properties
public List<Team> Teams { get; set; } = [];
public List<Student> Students { get; set; } = [];
}
+38
View File
@@ -0,0 +1,38 @@
namespace Core.Services;
/// <summary>
/// Service for managing note naming conventions throughout the system.
/// Centralizes the logic for generating note titles for meeting notes and page notes.
/// </summary>
public interface INoteNamingService
{
/// <summary>
/// Gets the title for a meeting note based on the meeting date.
/// Format: "#Meeting Notes MM/dd/yyyy"
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The formatted meeting note title</returns>
string GetMeetingNoteTitle(DateTime meetingDate);
/// <summary>
/// Gets the title for a page note based on the page identifier.
/// Format: "#{pageIdentifier}"
/// </summary>
/// <param name="pageIdentifier">The page identifier (e.g., "students", "teams")</param>
/// <returns>The formatted page note title</returns>
string GetPageNoteTitle(string pageIdentifier);
/// <summary>
/// Checks if a note title represents a page note (starts with "#").
/// </summary>
/// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a page note, false otherwise</returns>
bool IsPageNote(string noteTitle);
/// <summary>
/// Checks if a note title represents a meeting note (starts with "#Meeting Notes").
/// </summary>
/// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a meeting note, false otherwise</returns>
bool IsMeetingNote(string noteTitle);
}
+50
View File
@@ -0,0 +1,50 @@
namespace Core.Services;
/// <summary>
/// Implementation of INoteNamingService that provides note naming conventions.
/// Uses "#" as the prefix for page notes and meeting notes.
/// </summary>
public class NoteNamingService : INoteNamingService
{
private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes";
/// <inheritdoc/>
public string GetMeetingNoteTitle(DateTime meetingDate)
{
return $"{MeetingNotePrefix} {meetingDate:MM/dd/yyyy}";
}
/// <inheritdoc/>
public string GetPageNoteTitle(string pageIdentifier)
{
if (string.IsNullOrWhiteSpace(pageIdentifier))
{
throw new ArgumentException("Page identifier cannot be null or empty", nameof(pageIdentifier));
}
return $"{PageNotePrefix}{pageIdentifier}";
}
/// <inheritdoc/>
public bool IsPageNote(string noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
{
return false;
}
return noteTitle.StartsWith(PageNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public bool IsMeetingNote(string noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
{
return false;
}
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
}
}
+1
View File
@@ -14,6 +14,7 @@ namespace Data
public DbSet<Career> Careers { get; set; }
public DbSet<Note> Notes { get; set; }
public DbSet<NoteHistory> NoteHistories { get; set; }
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
public AppDbContext()
{
@@ -0,0 +1,29 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations
{
public class TeamMeetingHistoryConfiguration : IEntityTypeConfiguration<TeamMeetingHistory>
{
public void Configure(EntityTypeBuilder<TeamMeetingHistory> builder)
{
builder.HasKey(tmh => tmh.Id);
// Indexes
builder.HasIndex(tmh => tmh.MeetingDate);
// Constraints
builder.Property(tmh => tmh.MeetingDate)
.IsRequired();
builder.HasMany(tmh => tmh.Teams)
.WithMany()
.UsingEntity(j => j.ToTable("TeamMeetingHistoryTeams"));
builder.HasMany(tmh => tmh.Students)
.WithMany()
.UsingEntity(j => j.ToTable("TeamMeetingHistoryStudents"));
}
}
}
@@ -0,0 +1,567 @@
// <auto-generated />
using System;
using Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260120024048_AddTeamMeetingHistory")]
partial class AddTeamMeetingHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.Entity("CareerEventDefinition", b =>
{
b.Property<int>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("RelatedCareersId")
.HasColumnType("INTEGER");
b.HasKey("EventDefinitionId", "RelatedCareersId");
b.HasIndex("RelatedCareersId");
b.ToTable("EventDefinitionCareers", (string)null);
});
modelBuilder.Entity("Core.Entities.Career", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Careers");
});
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterEligibilityCountRegionals")
.HasColumnType("INTEGER");
b.Property<int>("ChapterEligibilityCountState")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Documentation")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Eligibility")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("EventFormat")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("LevelOfEffort")
.HasColumnType("INTEGER");
b.Property<int>("MaxTeamSize")
.HasColumnType("INTEGER");
b.Property<int>("MinTeamSize")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("OnSiteActivity")
.HasColumnType("INTEGER");
b.Property<bool>("Presubmission")
.HasColumnType("INTEGER");
b.Property<string>("SemifinalistActivity")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ShortName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Theme")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventFormat");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Events");
});
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Date")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndTime")
.HasColumnType("TEXT");
b.Property<int?>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SpecialEventType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("StartTime")
.HasColumnType("TEXT");
b.Property<string>("Time")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventDefinitionId");
b.HasIndex("SpecialEventType");
b.HasIndex("StartTime");
b.ToTable("EventOccurrences");
});
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPinned")
.HasColumnType("INTEGER");
b.Property<string>("LastModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("IsDeleted");
b.HasIndex("IsPinned");
b.HasIndex("Title");
b.ToTable("Notes");
});
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ChangeType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<string>("ModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<int>("NoteId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ModifiedAt");
b.HasIndex("NoteId");
b.HasIndex("NoteId", "ModifiedAt");
b.ToTable("NoteHistories");
});
modelBuilder.Entity("Core.Entities.Student", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Grade")
.HasColumnType("INTEGER");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("NationalId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("OfficerRole")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("RegionalId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("StateId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("TsaYear")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Email");
b.HasIndex("Grade");
b.HasIndex("FirstName", "LastName");
b.ToTable("Students");
});
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
{
b.Property<int>("StudentId")
.HasColumnType("INTEGER");
b.Property<int>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.HasKey("StudentId", "EventDefinitionId");
b.HasIndex("EventDefinitionId");
b.HasIndex("Rank");
b.HasIndex("StudentId");
b.ToTable("StudentEventRanking");
});
modelBuilder.Entity("Core.Entities.Team", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("CaptainId")
.HasColumnType("INTEGER");
b.Property<int>("EventId")
.HasColumnType("INTEGER");
b.Property<string>("Identifier")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CaptainId");
b.HasIndex("EventId");
b.HasIndex("EventId", "Identifier");
b.ToTable("Teams");
});
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("MeetingDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MeetingDate");
b.ToTable("TeamMeetingHistories");
});
modelBuilder.Entity("StudentTeam", b =>
{
b.Property<int>("StudentsId")
.HasColumnType("INTEGER");
b.Property<int>("TeamsId")
.HasColumnType("INTEGER");
b.HasKey("StudentsId", "TeamsId");
b.HasIndex("TeamsId");
b.ToTable("TeamStudents", (string)null);
});
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
{
b.Property<int>("StudentsId")
.HasColumnType("INTEGER");
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.HasKey("StudentsId", "TeamMeetingHistoryId");
b.HasIndex("TeamMeetingHistoryId");
b.ToTable("TeamMeetingHistoryStudents", (string)null);
});
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
{
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.Property<int>("TeamsId")
.HasColumnType("INTEGER");
b.HasKey("TeamMeetingHistoryId", "TeamsId");
b.HasIndex("TeamsId");
b.ToTable("TeamMeetingHistoryTeams", (string)null);
});
modelBuilder.Entity("CareerEventDefinition", b =>
{
b.HasOne("Core.Entities.EventDefinition", null)
.WithMany()
.HasForeignKey("EventDefinitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Career", null)
.WithMany()
.HasForeignKey("RelatedCareersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
{
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
.WithMany()
.HasForeignKey("EventDefinitionId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("EventDefinition");
});
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
{
b.HasOne("Core.Entities.Note", "Note")
.WithMany("NoteHistories")
.HasForeignKey("NoteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Note");
});
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
{
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
.WithMany()
.HasForeignKey("EventDefinitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Student", "Student")
.WithMany("EventRankings")
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("EventDefinition");
b.Navigation("Student");
});
modelBuilder.Entity("Core.Entities.Team", b =>
{
b.HasOne("Core.Entities.Student", "Captain")
.WithMany()
.HasForeignKey("CaptainId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Core.Entities.EventDefinition", "Event")
.WithMany()
.HasForeignKey("EventId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Captain");
b.Navigation("Event");
});
modelBuilder.Entity("StudentTeam", b =>
{
b.HasOne("Core.Entities.Student", null)
.WithMany()
.HasForeignKey("StudentsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Team", null)
.WithMany()
.HasForeignKey("TeamsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
{
b.HasOne("Core.Entities.Student", null)
.WithMany()
.HasForeignKey("StudentsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.TeamMeetingHistory", null)
.WithMany()
.HasForeignKey("TeamMeetingHistoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
{
b.HasOne("Core.Entities.TeamMeetingHistory", null)
.WithMany()
.HasForeignKey("TeamMeetingHistoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Team", null)
.WithMany()
.HasForeignKey("TeamsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Navigation("NoteHistories");
});
modelBuilder.Entity("Core.Entities.Student", b =>
{
b.Navigation("EventRankings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,104 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
public partial class AddTeamMeetingHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeamMeetingHistories",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MeetingDate = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMeetingHistories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TeamMeetingHistoryStudents",
columns: table => new
{
StudentsId = table.Column<int>(type: "INTEGER", nullable: false),
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMeetingHistoryStudents", x => new { x.StudentsId, x.TeamMeetingHistoryId });
table.ForeignKey(
name: "FK_TeamMeetingHistoryStudents_Students_StudentsId",
column: x => x.StudentsId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TeamMeetingHistoryStudents_TeamMeetingHistories_TeamMeetingHistoryId",
column: x => x.TeamMeetingHistoryId,
principalTable: "TeamMeetingHistories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TeamMeetingHistoryTeams",
columns: table => new
{
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false),
TeamsId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMeetingHistoryTeams", x => new { x.TeamMeetingHistoryId, x.TeamsId });
table.ForeignKey(
name: "FK_TeamMeetingHistoryTeams_TeamMeetingHistories_TeamMeetingHistoryId",
column: x => x.TeamMeetingHistoryId,
principalTable: "TeamMeetingHistories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TeamMeetingHistoryTeams_Teams_TeamsId",
column: x => x.TeamsId,
principalTable: "Teams",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_TeamMeetingHistories_MeetingDate",
table: "TeamMeetingHistories",
column: "MeetingDate");
migrationBuilder.CreateIndex(
name: "IX_TeamMeetingHistoryStudents_TeamMeetingHistoryId",
table: "TeamMeetingHistoryStudents",
column: "TeamMeetingHistoryId");
migrationBuilder.CreateIndex(
name: "IX_TeamMeetingHistoryTeams_TeamsId",
table: "TeamMeetingHistoryTeams",
column: "TeamsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeamMeetingHistoryStudents");
migrationBuilder.DropTable(
name: "TeamMeetingHistoryTeams");
migrationBuilder.DropTable(
name: "TeamMeetingHistories");
}
}
}
@@ -370,6 +370,22 @@ namespace Data.Migrations
b.ToTable("Teams");
});
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("MeetingDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MeetingDate");
b.ToTable("TeamMeetingHistories");
});
modelBuilder.Entity("StudentTeam", b =>
{
b.Property<int>("StudentsId")
@@ -385,6 +401,36 @@ namespace Data.Migrations
b.ToTable("TeamStudents", (string)null);
});
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
{
b.Property<int>("StudentsId")
.HasColumnType("INTEGER");
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.HasKey("StudentsId", "TeamMeetingHistoryId");
b.HasIndex("TeamMeetingHistoryId");
b.ToTable("TeamMeetingHistoryStudents", (string)null);
});
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
{
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.Property<int>("TeamsId")
.HasColumnType("INTEGER");
b.HasKey("TeamMeetingHistoryId", "TeamsId");
b.HasIndex("TeamsId");
b.ToTable("TeamMeetingHistoryTeams", (string)null);
});
modelBuilder.Entity("CareerEventDefinition", b =>
{
b.HasOne("Core.Entities.EventDefinition", null)
@@ -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");
@@ -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
<PageHeader Title="Team Meeting Schedule History">
<ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Outlined"
Color="Color.Primary"
Href="/meeting-schedule">
Back to Schedule
</MudButton>
</ActionButtons>
</PageHeader>
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else
{
<MudStack Spacing="3">
@if (_meetingHistories.Any())
{
<MudPaper Elevation="1" Class="pa-3" Style="overflow-x: auto;">
<MudTable Items="_meetingHistories"
Hover="true"
Striped="true"
Dense="true"
Breakpoint="Breakpoint.Sm">
<ColGroup>
<col style="width: 100px;" />
@foreach (var team in _allTeams)
{
<col style="width: 40px;" />
}
</ColGroup>
<HeaderContent>
<MudTh Style="padding: 4px 8px; border-right: 2px solid var(--mud-palette-divider);">
<MudText Typo="Typo.caption" Style="writing-mode: vertical-rl; text-orientation: mixed;">
Date
</MudText>
</MudTh>
@{
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}";
<MudTh Style="@headerStyle">
<MudText Typo="Typo.caption" Style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">
@team.ToString()
</MudText>
</MudTh>
teamIndex++;
}
</HeaderContent>
<RowTemplate>
<MudTd Style="padding: 4px 8px; border-right: 2px solid var(--mud-palette-divider);">
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => ViewMeetingDetails(context))"
Style="text-transform: none; padding: 2px 4px;">
@context.MeetingDate.ToString("MM/dd/yy")
</MudButton>
</MudTd>
@{
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}";
<MudTd Align="Align.Center" Style="@cellStyle">
@if (met)
{
<MudText Typo="Typo.body1" Style="font-weight: bold;">×</MudText>
}
</MudTd>
rowTeamIndex++;
}
</RowTemplate>
<FooterContent>
<MudTd Style="padding: 4px 8px; border-right: 2px solid var(--mud-palette-divider); font-weight: bold;">
Times Met
</MudTd>
@{
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}";
<MudTd Align="Align.Center" Style="@footerCellStyle">
<MudText Typo="Typo.body2" Style="font-weight: bold;">@timesMet</MudText>
</MudTd>
footerTeamIndex++;
}
</FooterContent>
</MudTable>
</MudPaper>
}
else
{
<MudAlert Severity="Severity.Info">No meeting history found. Save a meeting schedule to get started.</MudAlert>
}
</MudStack>
}
</MudPaper>
@code {
private List<TeamMeetingHistory> _meetingHistories = [];
private List<Team> _allTeams = [];
private Dictionary<int, int> _timesMetDict = new();
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData()
{
if (_isDisposed) return;
try
{
_isLoading = true;
StateHasChanged();
// Load all teams for columns
_allTeams = await Context.Teams
.AsNoTracking()
.Include(t => t.Event)
.OrderBy(t => t.Event.Name)
.ThenBy(t => t.Identifier)
.ToListAsync();
// Load meeting histories
await RefreshMeetingHistories();
// Calculate times met
CalculateTimesMet();
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
}
}
finally
{
if (!_isDisposed)
{
_isLoading = false;
StateHasChanged();
}
}
}
private async Task RefreshMeetingHistories()
{
_meetingHistories = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync()).ToList();
// Extract all unique teams from meeting histories and merge with all teams
var teamsInHistory = _meetingHistories
.SelectMany(mh => mh.Teams)
.DistinctBy(t => t.Id)
.ToList();
// Merge with all teams, ensuring all teams are included
var allTeamIds = _allTeams.Select(t => t.Id).ToHashSet();
var newTeams = teamsInHistory.Where(t => !allTeamIds.Contains(t.Id)).ToList();
_allTeams = _allTeams.Concat(newTeams).OrderBy(t => t.Event?.Name ?? "").ThenBy(t => t.Identifier).ToList();
}
private void CalculateTimesMet()
{
_timesMetDict.Clear();
foreach (var team in _allTeams)
{
_timesMetDict[team.Id] = 0;
}
foreach (var history in _meetingHistories)
{
var teamIds = history.Teams.Select(t => t.Id).ToHashSet();
foreach (var teamId in teamIds)
{
if (_timesMetDict.ContainsKey(teamId))
{
_timesMetDict[teamId]++;
}
}
}
}
private int GetTimesMetForTeam(Team team)
{
return _timesMetDict.GetValueOrDefault(team.Id, 0);
}
private bool TeamMetOnDate(TeamMeetingHistory history, Team team)
{
return history.Teams.Any(t => t.Id == team.Id);
}
private async Task ViewMeetingDetails(TeamMeetingHistory history)
{
var parameters = new DialogParameters
{
["MeetingHistoryId"] = history.Id
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
var result = await dialog.Result;
if (!result.Canceled)
{
// Refresh data if meeting was updated or deleted
await RefreshMeetingHistories();
CalculateTimesMet();
if (!_isDisposed)
{
StateHasChanged();
}
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -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
<PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")">
<ActionButtons>
<MudTooltip Text="View meeting history">
<MudButton StartIcon="@Icons.Material.Filled.History"
Variant="Variant.Outlined"
Color="Color.Default"
Href="/meeting-schedule/history">
View History
</MudButton>
</MudTooltip>
<MudTooltip Text="Save current schedule as meeting history">
<MudButton StartIcon="@Icons.Material.Filled.Save"
Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="OpenSaveHistoryDialog"
Disabled="@(!_scheduledTeams.Any())">
Save as History
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Meeting Schedule" />
</ActionButtons>
</PageHeader>
@@ -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<SaveMeetingHistoryDialog>("Save Meeting History", parameters, options);
var result = await dialog.Result;
// Note: Success message is already shown in the dialog, no need to show another here
}
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
{
var timeSlotIndex = GetTimeSlotIndex(timeslot.Name);
@@ -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
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (_meetingHistory == null)
{
<MudAlert Severity="Severity.Error">Meeting history not found.</MudAlert>
}
else
{
<MudStack Spacing="3">
<MudText Typo="Typo.h6">Meeting Details</MudText>
<MudPaper Elevation="1" Class="pa-3">
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2">
<strong>Date:</strong> @_meetingHistory.MeetingDate.ToString("MM/dd/yyyy")
</MudText>
</MudStack>
</MudPaper>
<MudDivider />
<MudText Typo="Typo.subtitle1">Teams That Met (@_meetingHistory.Teams.Count)</MudText>
<MudPaper Elevation="1" Class="pa-2">
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="@AppIcons.TeamChipVariant()" Class="mx-1 my-1">@team.ToString()</MudChip>
}
</MudStack>
</MudPaper>
<MudDivider />
<MudText Typo="Typo.subtitle1">Students (@GetAllStudentsFromTeams().Count)</MudText>
<MudPaper Elevation="1" Class="pa-2">
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@{
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName);
}
@foreach (var student in allStudents)
{
var isPresent = presentStudentIds.Contains(student.Id);
<MudChip T="string"
Size="Size.Small"
Color="Color.Default"
Variant="@AppIcons.StudentChipVariant()"
Class="mx-1 my-1"
Style="@(!isPresent ? "opacity: 0.5;" : "")">
@student.FirstNameLastName
</MudChip>
}
</MudStack>
</MudPaper>
@if (_meetingNote != null)
{
<MudDivider />
<MudText Typo="Typo.subtitle1">Meeting Notes</MudText>
<MudPaper Elevation="1" Class="pa-3">
<MudText Typo="Typo.body2"><strong>@_meetingNote.Title</strong></MudText>
@if (!string.IsNullOrWhiteSpace(_meetingNote.Content))
{
<MudText Typo="Typo.body2" Class="mt-2">
@((MarkupString)MarkdownHelper.ToHtml(_meetingNote.Content))
</MudText>
}
<MudButton Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Edit"
OnClick="ViewNote"
Class="mt-2">
Edit Note
</MudButton>
</MudPaper>
}
</MudStack>
}
</DialogContent>
<DialogActions>
@if (_meetingHistory != null)
{
<MudButton Variant="Variant.Text"
Color="Color.Error"
OnClick="ConfirmDelete"
Disabled="@IsActionDisabled">
Delete
</MudButton>
<MudSpacer />
<MudButton OnClick="Close" Disabled="@IsActionDisabled">Close</MudButton>
}
else
{
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
}
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public int MeetingHistoryId { get; set; }
private TeamMeetingHistory? _meetingHistory;
private Note? _meetingNote;
private bool _isLoading = true;
private bool _isDeleting = false;
private 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<NoteViewDialog>("Meeting Notes", parameters, options);
await dialog.Result;
// Refresh meeting history to get updated note
await LoadMeetingHistory();
}
private List<Student> GetAllStudentsFromTeams()
{
if (_meetingHistory == null)
return [];
var allStudents = new List<Student>();
var studentIds = new HashSet<int>();
foreach (var team in _meetingHistory.Teams)
{
foreach (var student in team.Students)
{
if (!studentIds.Contains(student.Id))
{
studentIds.Add(student.Id);
allStudents.Add(student);
}
}
}
return allStudents;
}
private void Close()
{
if (_isDisposed) return;
MudDialog.Close();
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -0,0 +1,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
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else
{
<MudStack Spacing="3">
<MudText Typo="Typo.h6">Save Meeting History</MudText>
<MudDatePicker Label="Meeting Date"
Date="_meetingDate"
DateChanged="OnMeetingDateChanged"
Variant="Variant.Outlined" />
<MudDivider />
<MudText Typo="Typo.subtitle1">Teams That Met</MudText>
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
<MudToggleGroup T="Team"
SelectionMode="SelectionMode.MultiSelection"
Values="_selectedTeams"
ValuesChanged="OnTeamsChanged"
Vertical="true"
CheckMark>
@foreach (var team in AllTeams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
{
<MudToggleItem Value="@team" Style="font-size: .75rem;">
@team.ToString()
</MudToggleItem>
}
</MudToggleGroup>
</MudPaper>
<MudDivider />
<MudText Typo="Typo.subtitle1">Students Present</MudText>
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
<MudToggleGroup T="Student"
SelectionMode="SelectionMode.MultiSelection"
Values="_selectedStudents"
ValuesChanged="OnStudentsChanged"
Vertical="true"
CheckMark>
@foreach (var student in AllStudents.OrderBy(s => s.FirstName))
{
<MudToggleItem Value="@student" Style="font-size: .75rem;">
@student.FirstNameLastName
</MudToggleItem>
}
</MudToggleGroup>
</MudPaper>
<MudDivider />
<MudExpansionPanels MultiExpansion="false">
<MudExpansionPanel Text="Meeting Notes (Optional)">
<MudStack Spacing="2">
<MudTextField T="string"
Label="Note Title"
@bind-Value="_noteTitle"
Variant="Variant.Outlined"
ReadOnly="true"
HelperText="Title is automatically generated based on meeting date" />
<MudTextField T="string"
Label="Note Content"
@bind-Value="_noteContent"
Variant="Variant.Outlined"
Lines="5"
Placeholder="Enter meeting notes..."
HelperText="Optional markdown content for meeting notes" />
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="@_isSaving">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@IsSaveDisabled">
@if (_isSaving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Saving...</span>
}
else
{
<span>Save</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
[Parameter]
public IEnumerable<Student> AbsentStudents { get; set; } = [];
[Parameter]
public IEnumerable<Team> AllTeams { get; set; } = [];
[Parameter]
public IEnumerable<Student> AllStudents { get; set; } = [];
private DateTime? _meetingDate = DateTime.Today;
private List<Team> _selectedTeams = [];
private List<Student> _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<Team> teams)
{
_selectedTeams = teams.ToList();
StateHasChanged();
}
private void OnStudentsChanged(IEnumerable<Student> 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;
}
}
+3 -1
View File
@@ -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()
{
+4 -2
View File
@@ -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)
</span>
</MudText>
@if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded)
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
{
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
OnClick="() => TogglePin(note)"
@@ -386,7 +388,7 @@
return false;
// Can't pin page notes
if (note.Title.StartsWith("@"))
if (NoteNamingService.IsPageNote(note.Title))
return true;
// Can't pin deleted notes
@@ -6,8 +6,7 @@
Class="@WrapperClass"
@onmouseenter="@(() => _isHovered = true)"
@onmouseleave="@(() => _isHovered = false)"
@ontouchstart="@HandleTouchStart"
@ontouchend="@((e) => { e.PreventDefault(); })">
@ontouchstart="@HandleTouchStart">
<MudChip T="string"
Size="@Size"
Color="@Color"
@@ -71,7 +70,6 @@
private void HandleTouchStart(TouchEventArgs e)
{
_isTouched = !_isTouched;
e.StopPropagation();
StateHasChanged();
}
}
@@ -1,5 +1,7 @@
@namespace WebApp.Components.Shared.Components
@using Core.Services
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject ISnackbar Snackbar
@inject MarkdownTablePasteService MarkdownTablePasteService
@implements IAsyncDisposable
@@ -83,7 +85,7 @@
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
_pageNoteTitle = $"@{PageIdentifier}";
_pageNoteTitle = NoteNamingService.GetPageNoteTitle(PageIdentifier);
_note = new Note
{
Title = _pageNoteTitle,
+2
View File
@@ -194,6 +194,8 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
builder.Services.AddScoped<WebApp.Services.ITeamMeetingHistoryService, WebApp.Services.TeamMeetingHistoryService>();
builder.Services.AddScoped<Core.Services.INoteNamingService, Core.Services.NoteNamingService>();
builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
// State container for maintaining state per user connection (Blazor Server)
+1 -1
View File
@@ -19,7 +19,7 @@ public interface INotesService
/// Gets a page-specific note by page identifier.
/// </summary>
/// <param name="pageIdentifier">The page identifier (e.g., "Teams", "Registration")</param>
/// <returns>The note with title "@{pageIdentifier}" or null if not found</returns>
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
Task<Note?> GetPageNoteAsync(string pageIdentifier);
/// <summary>
@@ -0,0 +1,50 @@
using Core.Entities;
namespace WebApp.Services;
public interface ITeamMeetingHistoryService
{
/// <summary>
/// Gets all meeting histories within an optional date range.
/// </summary>
/// <param name="startDate">Optional start date filter</param>
/// <param name="endDate">Optional end date filter</param>
Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null);
/// <summary>
/// Gets a specific meeting history by ID with teams and students included.
/// </summary>
Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id);
/// <summary>
/// Creates a new meeting history record.
/// </summary>
Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
/// <summary>
/// Updates an existing meeting history record.
/// </summary>
Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
/// <summary>
/// Deletes a meeting history record.
/// </summary>
Task DeleteMeetingHistoryAsync(int id);
/// <summary>
/// Gets which teams met on a specific date.
/// </summary>
Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date);
/// <summary>
/// Gets which students were present on a specific date.
/// </summary>
Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date);
/// <summary>
/// Gets the meeting note for a specific meeting date by looking it up by title.
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The note if found, null otherwise</returns>
Task<Note?> GetMeetingNoteAsync(DateTime meetingDate);
}
+12 -8
View File
@@ -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<NotesService> _logger;
private readonly INoteNamingService _noteNamingService;
public NotesService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<NotesService> logger)
ILogger<NotesService> 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<Note?> 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();
}
@@ -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<TeamMeetingHistoryService> _logger;
private readonly INotesService _notesService;
private readonly INoteNamingService _noteNamingService;
public TeamMeetingHistoryService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<TeamMeetingHistoryService> logger,
INotesService notesService,
INoteNamingService noteNamingService)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_notesService = notesService;
_noteNamingService = noteNamingService;
}
private string? GetCurrentUserEmail()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user == null)
return null;
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
}
public async Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null)
{
var query = _context.TeamMeetingHistories
.AsNoTracking()
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Students)
.AsQueryable();
if (startDate.HasValue)
{
var start = startDate.Value.Date;
query = query.Where(tmh => tmh.MeetingDate >= start);
}
if (endDate.HasValue)
{
var end = endDate.Value.Date.AddDays(1); // Include the entire end date
query = query.Where(tmh => tmh.MeetingDate < end);
}
return await query
.OrderByDescending(tmh => tmh.MeetingDate)
.ToListAsync();
}
public async Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id)
{
return await _context.TeamMeetingHistories
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Students)
.Include(tmh => tmh.Students)
.FirstOrDefaultAsync(tmh => tmh.Id == id);
}
public async Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
{
// Create a new meeting history entity to avoid tracking conflicts
var newMeetingHistory = new TeamMeetingHistory
{
MeetingDate = meetingHistory.MeetingDate.Date // Normalize to date only
};
// Attach teams by loading them from the database to avoid tracking conflicts
var teamIds = meetingHistory.Teams.Select(t => t.Id).ToList();
var teams = await _context.Teams
.Where(t => teamIds.Contains(t.Id))
.ToListAsync();
// Attach students by loading them from the database to avoid tracking conflicts
var studentIds = meetingHistory.Students.Select(s => s.Id).ToList();
var students = await _context.Students
.Where(s => studentIds.Contains(s.Id))
.ToListAsync();
// Attach teams and students to the new meeting history
newMeetingHistory.Teams = teams;
newMeetingHistory.Students = students;
_context.TeamMeetingHistories.Add(newMeetingHistory);
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history created: {MeetingHistoryId} for date {MeetingDate}",
newMeetingHistory.Id, newMeetingHistory.MeetingDate);
return newMeetingHistory;
}
public async Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
{
var existingHistory = await _context.TeamMeetingHistories
.Include(tmh => tmh.Teams)
.Include(tmh => tmh.Students)
.FirstOrDefaultAsync(tmh => tmh.Id == meetingHistory.Id);
if (existingHistory == null)
{
throw new InvalidOperationException($"Meeting history with ID {meetingHistory.Id} not found.");
}
// Update properties
existingHistory.MeetingDate = meetingHistory.MeetingDate.Date; // Normalize to date only
// Update teams
existingHistory.Teams.Clear();
foreach (var team in meetingHistory.Teams)
{
var existingTeam = await _context.Teams.FindAsync(team.Id);
if (existingTeam != null)
{
existingHistory.Teams.Add(existingTeam);
}
}
// Update students
existingHistory.Students.Clear();
foreach (var student in meetingHistory.Students)
{
var existingStudent = await _context.Students.FindAsync(student.Id);
if (existingStudent != null)
{
existingHistory.Students.Add(existingStudent);
}
}
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history updated: {MeetingHistoryId} by {User}",
meetingHistory.Id, GetCurrentUserEmail());
return existingHistory;
}
public async Task DeleteMeetingHistoryAsync(int id)
{
var meetingHistory = await _context.TeamMeetingHistories
.FirstOrDefaultAsync(tmh => tmh.Id == id);
if (meetingHistory == null)
{
throw new InvalidOperationException($"Meeting history with ID {id} not found.");
}
_context.TeamMeetingHistories.Remove(meetingHistory);
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history deleted: {MeetingHistoryId}", id);
}
/// <summary>
/// Gets the meeting note for a specific meeting date by looking it up by title.
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The note if found, null otherwise</returns>
public async Task<Note?> GetMeetingNoteAsync(DateTime meetingDate)
{
var noteTitle = _noteNamingService.GetMeetingNoteTitle(meetingDate);
var notes = await _notesService.GetNotesAsync(includeDeleted: false);
return notes.FirstOrDefault(n => n.Title == noteTitle);
}
public async Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date)
{
var normalizedDate = date.Date;
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.MeetingDate == normalizedDate)
.SelectMany(tmh => tmh.Teams)
.Include(t => t.Event)
.Distinct()
.ToListAsync();
}
public async Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date)
{
var normalizedDate = date.Date;
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.MeetingDate == normalizedDate)
.SelectMany(tmh => tmh.Students)
.Distinct()
.ToListAsync();
}
}