From fe36f5188696920a401979bd48220086e2c7e8fa Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 01:01:40 +0200 Subject: [PATCH] feat: add seating plans for learning groups --- LehrerApp.Core/Interfaces/IRepositories.cs | 7 + LehrerApp.Core/Models/SeatingPlan.cs | 25 ++ .../Services/PersonalDataExportService.cs | 15 +- LehrerApp.Data.Tests/RepositoryTests.cs | 58 ++++ LehrerApp.Data/LiteDbContext.cs | 2 + .../Repositories/AllRepositories.cs | 55 ++++ LehrerApp.Desktop.Tests/Fakes.cs | 14 + .../GroupDetailViewModelTests.cs | 3 +- .../PersonalDataExportServiceTests.cs | 15 +- .../SeatingPlanViewModelTests.cs | 51 +++ LehrerApp.Desktop/AppBootstrapper.cs | 2 + .../ViewModels/Groups/GroupViewModels.cs | 8 +- .../Groups/SeatingPlanViewModels.cs | 298 ++++++++++++++++++ .../Views/Groups/GroupDetailView.axaml | 5 + .../Views/Groups/SeatingPlanDialog.axaml | 55 ++++ .../Views/Groups/SeatingPlanDialog.axaml.cs | 19 ++ .../Views/Groups/SeatingPlanTabView.axaml | 96 ++++++ .../Views/Groups/SeatingPlanTabView.axaml.cs | 46 +++ TODO.md | 3 + 19 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 LehrerApp.Core/Models/SeatingPlan.cs create mode 100644 LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs create mode 100644 LehrerApp.Desktop/Views/Groups/SeatingPlanDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/SeatingPlanDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 9cbd4d5..50cce1d 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -19,6 +19,13 @@ public interface IGroupRepository void Save(LearningGroup group); void Delete(Guid id); } +public interface ISeatingPlanRepository +{ + SeatingPlan? GetById(Guid id); + List GetByGroup(Guid groupId); + void Save(SeatingPlan plan); + void Delete(Guid id); +} public interface IGroupMembershipRepository { List GetByStudent(Guid studentId); diff --git a/LehrerApp.Core/Models/SeatingPlan.cs b/LehrerApp.Core/Models/SeatingPlan.cs new file mode 100644 index 0000000..28840b3 --- /dev/null +++ b/LehrerApp.Core/Models/SeatingPlan.cs @@ -0,0 +1,25 @@ +namespace LehrerApp.Core.Models; + +/// +/// Ein Sitzplan einer Lerngruppe. Eine Gruppe kann mehrere Pläne besitzen, +/// beispielsweise für unterschiedliche Unterrichtsräume. +/// +public class SeatingPlan +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid GroupId { get; set; } + public string Name { get; set; } = ""; + public string Room { get; set; } = ""; + public int Rows { get; set; } = 4; + public int Columns { get; set; } = 4; + public List Assignments { get; set; } = []; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +public class SeatAssignment +{ + public int Row { get; set; } + public int Column { get; set; } + public Guid StudentId { get; set; } +} diff --git a/LehrerApp.Core/Services/PersonalDataExportService.cs b/LehrerApp.Core/Services/PersonalDataExportService.cs index 9dea1cf..eccdc66 100644 --- a/LehrerApp.Core/Services/PersonalDataExportService.cs +++ b/LehrerApp.Core/Services/PersonalDataExportService.cs @@ -21,7 +21,8 @@ public class PersonalDataExportService( IGradeRepository grades, IExamResultRepository examResults, IParticipationRepository participation, - IDocumentationRepository documentation) + IDocumentationRepository documentation, + ISeatingPlanRepository seatingPlans) { public string ExportAsJson(Guid studentId) { @@ -43,6 +44,18 @@ public class PersonalDataExportService( Klausurergebnisse = examResults.GetByStudent(studentId), Mitarbeit = participation.GetByStudent(studentId), Dokumentation = documentation.GetByStudent(studentId), + Sitzplaetze = studentGroups + .SelectMany(g => seatingPlans.GetByGroup(g!.Id)) + .SelectMany(plan => plan.Assignments + .Where(a => a.StudentId == studentId) + .Select(a => new + { + Sitzplan = plan.Name, + plan.Room, + Reihe = a.Row + 1, + Platz = a.Column + 1, + })) + .ToList(), }; return JsonSerializer.Serialize(dto, new JsonSerializerOptions diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 4c04c6c..4305302 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -189,6 +189,9 @@ public sealed class RepositoryTests var sectionId = Guid.NewGuid(); db.ParticipationSections.Insert(new ParticipationSection { Id = sectionId, GroupId = groupId }); + var seatingPlanId = Guid.NewGuid(); + db.SeatingPlans.Insert(new SeatingPlan { Id = seatingPlanId, GroupId = groupId, Name = "Raumplan" }); + var documentationId = Guid.NewGuid(); db.Documentation.Insert(new Documentation { Id = documentationId, GroupId = groupId, StudentId = studentId }); @@ -214,6 +217,7 @@ public sealed class RepositoryTests Assert.Null(db.ReportGrades.FindById(reportGradeId)); Assert.Null(db.GradingSchemes.FindById(gradingSchemeId)); Assert.Null(db.ParticipationSections.FindById(sectionId)); + Assert.Null(db.SeatingPlans.FindById(seatingPlanId)); Assert.Null(db.Documentation.FindById(documentationId)?.GroupId); Assert.Null(db.Tasks.FindById(taskId)?.GroupId); Assert.Null(db.TimeEntries.FindById(timeEntryId)?.GroupId); @@ -241,6 +245,60 @@ public sealed class RepositoryTests // ── GroupMembershipRepository ──────────────────────────────────────────── + [Fact] + public void SeatingPlanRepository_SpeichertMehrerePlaeneUndEindeutigeZuordnungen() + { + using var db = NewInMemoryContext(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var anna = new Student { FirstName = "Anna", LastName = "A" }; + var ben = new Student { FirstName = "Ben", LastName = "B" }; + db.Students.InsertBulk([anna, ben]); + db.Memberships.InsertBulk([ + new GroupMembership { GroupId = group.Id, StudentId = anna.Id }, + new GroupMembership { GroupId = group.Id, StudentId = ben.Id }, + ]); + var repo = new SeatingPlanRepository(db); + repo.Save(new SeatingPlan + { + GroupId = group.Id, Name = "Standard", Room = "B204", Rows = 2, Columns = 2, + Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id }], + }); + repo.Save(new SeatingPlan + { + GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3, + Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }], + }); + + var plans = repo.GetByGroup(group.Id); + + Assert.Equal(2, plans.Count); + Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id); + Assert.Contains(plans, p => p.Room == "Physik" && p.Assignments.Single().StudentId == ben.Id); + } + + [Fact] + public void SeatingPlanRepository_LehntDoppeltZugeordnetenSchuelerAb() + { + using var db = NewInMemoryContext(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var student = new Student { FirstName = "Anna", LastName = "A" }; + db.Students.Insert(student); + db.Memberships.Insert(new GroupMembership { GroupId = group.Id, StudentId = student.Id }); + var plan = new SeatingPlan + { + GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2, + Assignments = + [ + new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }, + new SeatAssignment { Row = 0, Column = 1, StudentId = student.Id }, + ], + }; + + Assert.Throws(() => new SeatingPlanRepository(db).Save(plan)); + } + [Fact] public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb() { diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 0539174..940859d 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -36,6 +36,7 @@ public class LiteDbContext : IDisposable public ILiteCollection Students => _db.GetCollection("students"); public ILiteCollection Groups => _db.GetCollection("groups"); + public ILiteCollection SeatingPlans => _db.GetCollection("seating_plans"); public ILiteCollection Memberships => _db.GetCollection("group_memberships"); public ILiteCollection Exams => _db.GetCollection("exams"); public ILiteCollection ExamResults => _db.GetCollection("exam_results"); @@ -333,6 +334,7 @@ public class LiteDbContext : IDisposable Students.EnsureIndex(x => x.IsActive); Groups.EnsureIndex(x => x.SchoolYear); Groups.EnsureIndex(x => x.IsActive); + SeatingPlans.EnsureIndex(x => x.GroupId); Memberships.EnsureIndex(x => x.StudentId); Memberships.EnsureIndex(x => x.GroupId); Memberships.EnsureIndex("ux_student_group", diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 7cd6833..22c07dc 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -121,6 +121,9 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList()) db.ParticipationSections.Delete(section.Id); + foreach (var plan in db.SeatingPlans.Find(p => p.GroupId == id).ToList()) + db.SeatingPlans.Delete(plan.Id); + // Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten, // werden aber von der nicht mehr existierenden Lerngruppe entkoppelt. foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList()) @@ -148,6 +151,58 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository } } +public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository +{ + public SeatingPlan? GetById(Guid id) => db.SeatingPlans.FindById(id); + + public List GetByGroup(Guid groupId) => + db.SeatingPlans.Find(p => p.GroupId == groupId) + .OrderBy(p => p.Name).ThenBy(p => p.Room).ToList(); + + public void Save(SeatingPlan plan) + { + ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); + plan.Name = plan.Name.Trim(); + plan.Room = plan.Room.Trim(); + if (plan.Name.Length == 0) + throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein."); + if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10) + throw new ArgumentOutOfRangeException(nameof(plan), "Ein Sitzplan muss zwischen 1 und 10 Reihen und Spalten haben."); + if (db.Groups.FindById(plan.GroupId) is null) + throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht."); + + var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId) + .FirstOrDefault(p => p.Id != plan.Id + && string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(p.Room, plan.Room, StringComparison.OrdinalIgnoreCase)); + if (duplicateName is not null) + throw new InvalidOperationException("Für diese Lerngruppe existiert bereits ein gleichnamiger Sitzplan in diesem Raum."); + + plan.Assignments = plan.Assignments + .Where(a => a.Row >= 0 && a.Row < plan.Rows && a.Column >= 0 && a.Column < plan.Columns) + .ToList(); + if (plan.Assignments.GroupBy(a => (a.Row, a.Column)).Any(g => g.Count() > 1)) + throw new InvalidOperationException("Ein Sitzplatz darf nur einmal belegt werden."); + if (plan.Assignments.GroupBy(a => a.StudentId).Any(g => g.Count() > 1)) + throw new InvalidOperationException("Ein Schüler darf in einem Sitzplan nur einmal vorkommen."); + + var memberIds = db.Memberships.Find(m => m.GroupId == plan.GroupId) + .Select(m => m.StudentId).ToHashSet(); + if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId))) + throw new InvalidOperationException("Der Sitzplan enthält einen Schüler, der nicht zur Lerngruppe gehört."); + + plan.UpdatedAt = DateTime.UtcNow; + db.SeatingPlans.Upsert(plan); + } + + public void Delete(Guid id) + { + if (db.SeatingPlans.FindById(id) is { } plan) + ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId); + db.SeatingPlans.Delete(id); + } +} + public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository { public List GetByStudent(Guid id) => diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index b23f3b3..df955f7 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -60,6 +60,20 @@ public class FakeMemberships(List all) : IGroupMembershipReposi public void Delete(Guid id) => all.RemoveAll(m => m.Id == id); } +public class FakeSeatingPlans(List? initial = null) : ISeatingPlanRepository +{ + private readonly List _all = initial ?? []; + public SeatingPlan? GetById(Guid id) => _all.FirstOrDefault(p => p.Id == id); + public List GetByGroup(Guid groupId) => + _all.Where(p => p.GroupId == groupId).OrderBy(p => p.Name).ToList(); + public void Save(SeatingPlan plan) + { + _all.RemoveAll(p => p.Id == plan.Id); + _all.Add(plan); + } + public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id); +} + public class FakeSessions(List all) : IParticipationSessionRepository { public List GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList(); diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs index 9921f72..f2993a3 100644 --- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -24,7 +24,8 @@ public sealed class GroupDetailViewModelTests new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), - new FakeCompetencyDomains(), students, new CompetencyAnalysisService())); + new FakeCompetencyDomains(), students, new CompetencyAnalysisService()), + new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships)); vm.LoadGroup(group.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); diff --git a/LehrerApp.Desktop.Tests/PersonalDataExportServiceTests.cs b/LehrerApp.Desktop.Tests/PersonalDataExportServiceTests.cs index 9c43c1f..0512002 100644 --- a/LehrerApp.Desktop.Tests/PersonalDataExportServiceTests.cs +++ b/LehrerApp.Desktop.Tests/PersonalDataExportServiceTests.cs @@ -24,13 +24,23 @@ public sealed class PersonalDataExportServiceTests var documentation = new FakeDocumentation(); documentation.Add(new Documentation { StudentId = studentId, Title = "Elterngespräch" }); - var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries, documentation); + var seatingPlans = new FakeSeatingPlans([ + new SeatingPlan + { + GroupId = groupId, Name = "Standard", Room = "B204", + Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = studentId }], + }, + ]); + var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries, + documentation, seatingPlans); var json = service.ExportAsJson(studentId); Assert.Contains("Anna", json); Assert.Contains("Elterngespräch", json); Assert.Contains("\"Value\": \"2\"", json); + Assert.Contains("\"Sitzplan\": \"Standard\"", json); + Assert.Contains("\"Reihe\": 2", json); } [Fact] @@ -38,7 +48,8 @@ public sealed class PersonalDataExportServiceTests { var service = new PersonalDataExportService( new FakeStudents([]), new FakeGroups([]), new FakeMemberships([]), - new FakeGrades(), new FakeResults(), new FakeEntries(), new FakeDocumentation()); + new FakeGrades(), new FakeResults(), new FakeEntries(), new FakeDocumentation(), + new FakeSeatingPlans()); Assert.Throws(() => service.ExportAsJson(Guid.NewGuid())); } diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs new file mode 100644 index 0000000..105d4ab --- /dev/null +++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs @@ -0,0 +1,51 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class SeatingPlanViewModelTests +{ + [Fact] + public void AuswahlEinesBereitsZugeordnetenSchuelers_VerschiebtIhnAufDenNeuenPlatz() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var students = new FakeStudents([student]); + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = groupId, StudentId = student.Id }, + ]); + var plan = new SeatingPlan + { + GroupId = groupId, + Name = "Standard", + Rows = 1, + Columns = 2, + Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }], + }; + var plans = new FakeSeatingPlans([plan]); + var vm = new SeatingPlanTabViewModel(plans, students, memberships); + vm.Initialize(groupId, isReadOnly: false); + + vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id); + + Assert.Null(vm.Seats[0].SelectedOption.StudentId); + var assignment = Assert.Single(plans.GetById(plan.Id)!.Assignments); + Assert.Equal((0, 1, student.Id), (assignment.Row, assignment.Column, assignment.StudentId)); + } + + [Fact] + public void ArchivierteGruppe_DeaktiviertSitzplatzbearbeitung() + { + var groupId = Guid.NewGuid(); + var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 }; + var vm = new SeatingPlanTabViewModel( + new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([])); + + vm.Initialize(groupId, isReadOnly: true); + + Assert.False(vm.IsEditable); + Assert.False(vm.Seats.Single().CanEdit); + Assert.False(vm.AddPlanCommand.CanExecute(null)); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index c3ebd51..f7bcdff 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -118,6 +118,7 @@ public static class AppBootstrapper // ── Repositories ────────────────────────────────────────────────────── services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -216,6 +217,7 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 636947c..034e995 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -208,7 +208,7 @@ public partial class GroupDetailViewModel : ObservableObject partial void OnShowFormerStudentsChanged(bool value) => LoadStudents(); partial void OnActiveTabIndexChanged(int value) { - if (value == 6) CompetencyOverviewTab.Refresh(); + if (value == 7) CompetencyOverviewTab.Refresh(); } public ObservableCollection Students { get; } = []; @@ -218,6 +218,7 @@ public partial class GroupDetailViewModel : ObservableObject public GradeOverviewTabViewModel GradeOverviewTab { get; } public PlanningTabViewModel PlanningTab { get; } public CompetencyOverviewTabViewModel CompetencyOverviewTab { get; } + public SeatingPlanTabViewModel SeatingPlanTab { get; } public Func>? OnAddStudent { get; set; } public Func>? OnWithdrawStudent { get; set; } public Func>? OnAddExam { get; set; } @@ -232,7 +233,8 @@ public partial class GroupDetailViewModel : ObservableObject IGroupMembershipRepository memberships, ISubjectRepository subjects, IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, - PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab) + PlanningTabViewModel planningTab, CompetencyOverviewTabViewModel competencyOverviewTab, + SeatingPlanTabViewModel seatingPlanTab) { _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; _exams = exams; _grades = grades; _tasks = tasks; @@ -240,6 +242,7 @@ public partial class GroupDetailViewModel : ObservableObject GradeOverviewTab = gradeOverviewTab; PlanningTab = planningTab; CompetencyOverviewTab = competencyOverviewTab; + SeatingPlanTab = seatingPlanTab; } public void LoadGroup(Guid id) @@ -259,6 +262,7 @@ public partial class GroupDetailViewModel : ObservableObject GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly); PlanningTab.Initialize(Group.Id, IsReadOnly); CompetencyOverviewTab.Initialize(Group); + SeatingPlanTab.Initialize(Group.Id, IsReadOnly); } private void ReloadExams() diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs new file mode 100644 index 0000000..c4eb1a4 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -0,0 +1,298 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public partial class SeatingPlanTabViewModel : ObservableObject +{ + private readonly ISeatingPlanRepository _plans; + private readonly IStudentRepository _students; + private readonly IGroupMembershipRepository _memberships; + private Guid _groupId; + private SeatingPlan? _currentPlan; + private bool _isReadOnly; + + [ObservableProperty] private SeatingPlanSummary? _selectedPlan; + [ObservableProperty] private int _planColumns = 1; + [ObservableProperty] private string _planTitle = ""; + [ObservableProperty] private string _planSubtitle = ""; + [ObservableProperty] private string _assignmentSummary = ""; + + public ObservableCollection Plans { get; } = []; + public ObservableCollection Seats { get; } = []; + public ObservableCollection StudentOptions { get; } = []; + + public bool HasPlans => Plans.Count > 0; + public bool HasSelectedPlan => _currentPlan is not null; + public bool IsEditable => !_isReadOnly; + public Func>? OnEditPlan { get; set; } + public Func>? OnConfirmDelete { get; set; } + + public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students, + IGroupMembershipRepository memberships) + { + _plans = plans; + _students = students; + _memberships = memberships; + } + + public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) => + new(_plans, _groupId, plan); + + public void Initialize(Guid groupId, bool isReadOnly) + { + _groupId = groupId; + _isReadOnly = isReadOnly; + LoadStudentOptions(); + ReloadPlans(); + OnPropertyChanged(nameof(IsEditable)); + NotifyCommands(); + } + + private void LoadStudentOptions() + { + StudentOptions.Clear(); + StudentOptions.Add(StudentSeatOption.Empty); + var today = DateOnly.FromDateTime(DateTime.Today); + var memberships = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId); + foreach (var student in _students.GetByGroup(_groupId) + .Where(s => memberships.TryGetValue(s.Id, out var membership) + && GroupMembershipService.IsActiveOn(membership, today)) + .OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) + StudentOptions.Add(new StudentSeatOption(student.Id, $"{student.LastName}, {student.FirstName}")); + } + + private void ReloadPlans(Guid? selectId = null) + { + selectId ??= SelectedPlan?.Id; + Plans.Clear(); + foreach (var plan in _plans.GetByGroup(_groupId)) + Plans.Add(new SeatingPlanSummary(plan)); + SelectedPlan = Plans.FirstOrDefault(p => p.Id == selectId) ?? Plans.FirstOrDefault(); + if (SelectedPlan is null) LoadPlan(null); + OnPropertyChanged(nameof(HasPlans)); + NotifyCommands(); + } + + partial void OnSelectedPlanChanged(SeatingPlanSummary? value) => + LoadPlan(value is null ? null : _plans.GetById(value.Id)); + + private void LoadPlan(SeatingPlan? plan) + { + _currentPlan = plan; + Seats.Clear(); + if (plan is null) + { + PlanColumns = 1; + PlanTitle = ""; + PlanSubtitle = ""; + AssignmentSummary = ""; + } + else + { + PlanColumns = plan.Columns; + PlanTitle = plan.Name; + PlanSubtitle = string.IsNullOrWhiteSpace(plan.Room) + ? $"{plan.Rows} × {plan.Columns} Plätze" + : $"Raum {plan.Room} · {plan.Rows} × {plan.Columns} Plätze"; + for (var row = 0; row < plan.Rows; row++) + for (var column = 0; column < plan.Columns; column++) + { + var assignment = plan.Assignments.FirstOrDefault(a => a.Row == row && a.Column == column); + var option = assignment is null + ? StudentSeatOption.Empty + : StudentOptions.FirstOrDefault(o => o.StudentId == assignment.StudentId) + ?? StudentSeatOption.Empty; + Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, IsEditable)); + } + UpdateAssignmentSummary(); + } + OnPropertyChanged(nameof(HasSelectedPlan)); + NotifyCommands(); + } + + private void OnSeatChanged(SeatCellViewModel changed) + { + if (_currentPlan is null || !IsEditable) return; + if (changed.SelectedOption.StudentId is Guid studentId) + { + foreach (var other in Seats.Where(s => s != changed && s.SelectedOption.StudentId == studentId)) + other.SetSelectionSilently(StudentSeatOption.Empty); + } + + _currentPlan.Assignments = Seats + .Where(s => s.SelectedOption.StudentId.HasValue) + .Select(s => new SeatAssignment + { + Row = s.Row, + Column = s.Column, + StudentId = s.SelectedOption.StudentId!.Value, + }).ToList(); + _plans.Save(_currentPlan); + UpdateAssignmentSummary(); + } + + private void UpdateAssignmentSummary() + { + var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue); + var total = StudentOptions.Count - 1; + AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet"; + } + + [RelayCommand(CanExecute = nameof(CanEdit))] + private async Task AddPlan() + { + if (OnEditPlan is null) return; + var plan = await OnEditPlan(null); + if (plan is not null) ReloadPlans(plan.Id); + } + + [RelayCommand(CanExecute = nameof(CanEditSelected))] + private async Task EditPlan() + { + if (_currentPlan is null || OnEditPlan is null) return; + var plan = await OnEditPlan(_currentPlan); + if (plan is not null) ReloadPlans(plan.Id); + } + + [RelayCommand(CanExecute = nameof(CanEditSelected))] + private async Task DeletePlan() + { + if (SelectedPlan is null || OnConfirmDelete is null || !await OnConfirmDelete(SelectedPlan)) return; + _plans.Delete(SelectedPlan.Id); + ReloadPlans(); + } + + private bool CanEdit() => IsEditable; + private bool CanEditSelected() => IsEditable && _currentPlan is not null; + + private void NotifyCommands() + { + AddPlanCommand.NotifyCanExecuteChanged(); + EditPlanCommand.NotifyCanExecuteChanged(); + DeletePlanCommand.NotifyCanExecuteChanged(); + } +} + +public sealed class SeatingPlanSummary +{ + public Guid Id { get; } + public string Name { get; } + public string RoomDisplay { get; } + + public SeatingPlanSummary(SeatingPlan plan) + { + Id = plan.Id; + Name = plan.Name; + RoomDisplay = string.IsNullOrWhiteSpace(plan.Room) ? "Kein Raum" : $"Raum {plan.Room}"; + } +} + +public sealed record StudentSeatOption(Guid? StudentId, string DisplayName) +{ + public static StudentSeatOption Empty { get; } = new(null, "— frei —"); +} + +public partial class SeatCellViewModel : ObservableObject +{ + private readonly Action _onChanged; + private bool _suppressChange; + + [ObservableProperty] private StudentSeatOption _selectedOption; + public int Row { get; } + public int Column { get; } + public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}"; + public ObservableCollection Options { get; } + public bool CanEdit { get; } + + public SeatCellViewModel(int row, int column, ObservableCollection options, + StudentSeatOption selectedOption, Action onChanged, bool canEdit) + { + Row = row; + Column = column; + Options = options; + _selectedOption = selectedOption; + _onChanged = onChanged; + CanEdit = canEdit; + } + + partial void OnSelectedOptionChanged(StudentSeatOption value) + { + if (!_suppressChange) _onChanged(this); + } + + public void SetSelectionSilently(StudentSeatOption option) + { + _suppressChange = true; + SelectedOption = option; + _suppressChange = false; + } +} + +public partial class SeatingPlanDialogViewModel : ObservableObject +{ + private readonly ISeatingPlanRepository _plans; + private readonly Guid _groupId; + private readonly SeatingPlan? _editingPlan; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _room = ""; + [ObservableProperty] private decimal _rows = 4; + [ObservableProperty] private decimal _columns = 4; + [ObservableProperty] private string _nameError = ""; + [ObservableProperty] private string _layoutError = ""; + + public SeatingPlan? Result { get; private set; } + public string DialogTitle => _editingPlan is null ? "Neuen Sitzplan anlegen" : "Sitzplan bearbeiten"; + public string SaveButtonText => _editingPlan is null ? "Anlegen" : "Speichern"; + + public SeatingPlanDialogViewModel(ISeatingPlanRepository plans, Guid groupId, SeatingPlan? editingPlan) + { + _plans = plans; + _groupId = groupId; + _editingPlan = editingPlan; + if (editingPlan is null) return; + Name = editingPlan.Name; + Room = editingPlan.Room; + Rows = editingPlan.Rows; + Columns = editingPlan.Columns; + } + + [RelayCommand] + private void Save() + { + NameError = ""; + LayoutError = ""; + var valid = true; + if (string.IsNullOrWhiteSpace(Name)) + { + NameError = "Bitte einen Namen eingeben."; + valid = false; + } + if (Rows is < 1 or > 10 || Columns is < 1 or > 10) + { + LayoutError = "Reihen und Plätze müssen zwischen 1 und 10 liegen."; + valid = false; + } + if (!valid) return; + + var plan = _editingPlan ?? new SeatingPlan { GroupId = _groupId }; + plan.Name = Name.Trim(); + plan.Room = Room.Trim(); + plan.Rows = decimal.ToInt32(Rows); + plan.Columns = decimal.ToInt32(Columns); + try + { + _plans.Save(plan); + Result = plan; + } + catch (InvalidOperationException ex) + { + NameError = ex.Message; + } + } +} diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml index be10056..cda8b18 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml @@ -101,6 +101,11 @@ + + + + + diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanDialog.axaml b/LehrerApp.Desktop/Views/Groups/SeatingPlanDialog.axaml new file mode 100644 index 0000000..8ecddbf --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanDialog.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +