From c5a1157a7d218c7dc4085b8ae89e101874a2dbba Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 20 Aug 2026 12:06:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20unbenutzte=20Sitzpl=C3=A4tze=20ausblend?= =?UTF-8?q?en=20k=C3=B6nnen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manche Räume sind unregelmäßig bestückt (z.B. eine Klasse mit 3 Reihen, in zwei davon fehlt ein Einzeltisch) - das feste Rows x Columns-Raster kennt aber keine per-Reihe abweichende Spaltenzahl. Neues Feld SeatingPlan.HiddenSeats für Plätze ohne physischen Tisch. Ein ausgeblendeter Platz bleibt Teil des Rasters (für die Spalten-/Reihenausrichtung von SeatingPlanPanel), wird im Ansichtsmodus aber nicht gerendert; im Bearbeitungsmodus bleibt er sichtbar (abgeblendet) mit einem Umschalt-Button direkt auf dem Platz. Nur leere Plätze lassen sich ausblenden; Zuweisen/ Verschieben eines Schülers auf einen ausgeblendeten Platz wird sowohl im ViewModel als auch im Drag&Drop-Ziel-Check verhindert. SeatingPlanRepository.Save filtert HiddenSeats zusätzlich auf das gültige Raster und blendet einen Platz automatisch wieder ein, falls er trotzdem belegt wird. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Models/SeatingPlan.cs | 13 ++ LehrerApp.Data.Tests/RepositoryTests.cs | 52 ++++++ .../Repositories/AllRepositories.cs | 13 ++ .../SeatingPlanViewModelTests.cs | 153 ++++++++++++++++++ .../Groups/SeatingPlanViewModels.cs | 74 ++++++++- .../Views/Groups/SeatingPlanTabView.axaml | 12 +- .../Views/Groups/SeatingPlanTabView.axaml.cs | 2 +- TODO.md | 23 +++ 8 files changed, 334 insertions(+), 8 deletions(-) diff --git a/LehrerApp.Core/Models/SeatingPlan.cs b/LehrerApp.Core/Models/SeatingPlan.cs index 55ded55..e84b570 100644 --- a/LehrerApp.Core/Models/SeatingPlan.cs +++ b/LehrerApp.Core/Models/SeatingPlan.cs @@ -23,6 +23,13 @@ public class SeatingPlan /// public List ColumnGapWidths { get; set; } = []; public List Assignments { get; set; } = []; + /// + /// Plätze, für die im Raum physisch kein Tisch existiert (z. B. eine Reihe mit einer Lücke) — + /// werden im Sitzplan ausgeblendet, bleiben aber Teil des Rasters, damit Reihen/Spalten + /// weiterhin korrekt ausgerichtet sind. Ein ausgeblendeter Platz kann nie belegt sein, siehe + /// SeatingPlanRepository.Save. + /// + public List HiddenSeats { get; set; } = []; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } @@ -33,3 +40,9 @@ public class SeatAssignment public int Column { get; set; } public Guid StudentId { get; set; } } + +public class HiddenSeat +{ + public int Row { get; set; } + public int Column { get; set; } +} diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index fac6f39..a2eb7dd 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -303,6 +303,58 @@ public sealed class RepositoryTests Assert.Throws(() => new SeatingPlanRepository(db).Save(plan)); } + [Fact] + public void SeatingPlanRepository_FiltertAusgeblendetePlaetzeAusserhalbDesRasters() + { + using var db = NewInMemoryContext(); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + new GroupRepository(db).Save(group); + var plan = new SeatingPlan + { + GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2, + HiddenSeats = + [ + new HiddenSeat { Row = 0, Column = 1 }, + new HiddenSeat { Row = 5, Column = 5 }, // außerhalb des 2x2-Rasters + ], + }; + + new SeatingPlanRepository(db).Save(plan); + + var saved = db.SeatingPlans.FindById(plan.Id); + Assert.NotNull(saved); + var hidden = Assert.Single(saved.HiddenSeats); + Assert.Equal((0, 1), (hidden.Row, hidden.Column)); + } + + /// Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist wertvoller + /// als das Ausblenden. SeatingPlanTabViewModel verhindert das Belegen ausgeblendeter Plätze + /// bereits in der UI; diese Absicherung greift für Daten, die diesen Weg umgehen (z. B. Sync + /// von einem älteren Client). + [Fact] + public void SeatingPlanRepository_BlendetPlatzWiederEinWennErBelegtWird() + { + 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, + HiddenSeats = [new HiddenSeat { Row = 0, Column = 0 }], + Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }], + }; + + new SeatingPlanRepository(db).Save(plan); + + var saved = db.SeatingPlans.FindById(plan.Id); + Assert.NotNull(saved); + Assert.Empty(saved.HiddenSeats); + Assert.Equal(student.Id, saved.Assignments.Single().StudentId); + } + [Fact] public void SeatingPlanRepository_AktualisiertPlanMitFehlendemOptionalenRaum() { diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 937a99f..17a7def 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -108,6 +108,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository plan.Room = plan.Room?.Trim() ?? ""; plan.Assignments ??= []; plan.ColumnGapWidths ??= []; + plan.HiddenSeats ??= []; 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) @@ -141,6 +142,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository 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."); + // Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist + // wertvoller als das Ausblenden, deshalb wird hier stillschweigend wieder eingeblendet + // statt die Zuordnung zu verwerfen. Sollte über die UI (SeatingPlanTabViewModel verhindert + // das Belegen ausgeblendeter Plätze) nie vorkommen - reine Absicherung bei fehlerhaften + // oder von einem älteren Client synchronisierten Daten. + var assignedPositions = plan.Assignments.Select(a => (a.Row, a.Column)).ToHashSet(); + plan.HiddenSeats = plan.HiddenSeats + .Where(h => h.Row >= 0 && h.Row < plan.Rows && h.Column >= 0 && h.Column < plan.Columns) + .Where(h => !assignedPositions.Contains((h.Row, h.Column))) + .DistinctBy(h => (h.Row, h.Column)) + .ToList(); + plan.UpdatedAt = DateTime.UtcNow; db.SeatingPlans.Upsert(plan); db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan); diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs index 53646fe..e3fcad4 100644 --- a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs @@ -115,6 +115,159 @@ public sealed class SeatingPlanViewModelTests Assert.Equal(2, plans.GetById(plan.Id)!.Assignments.Count); } + // ── Ausgeblendete Plätze (fehlender Tisch in einer Reihe) ───────────────────────────── + + [Fact] + public void LoadPlan_LaedtAusgeblendetePlaetzeAusDemPlan() + { + var groupId = Guid.NewGuid(); + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2, + HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }], + }; + var vm = new SeatingPlanTabViewModel( + new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]), + new FakeSessions([]), new FakeEntries(), new FakeAspects()); + + vm.Initialize(groupId, isReadOnly: false); + + Assert.False(vm.Seats[0].IsHidden); + Assert.True(vm.Seats[1].IsHidden); + } + + [Fact] + public void ToggleSeatHidden_LeererPlatz_BlendetAusUndSpeichertUndKannWiederEingeblendetWerden() + { + var groupId = Guid.NewGuid(); + var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2 }; + var plans = new FakeSeatingPlans([plan]); + var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([]), new FakeMemberships([]), + new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + vm.IsEditMode = true; + + vm.ToggleSeatHidden(vm.Seats[1]); + + Assert.True(vm.Seats[1].IsHidden); + var hidden = Assert.Single(plans.GetById(plan.Id)!.HiddenSeats); + Assert.Equal((0, 1), (hidden.Row, hidden.Column)); + + vm.ToggleSeatHidden(vm.Seats[1]); + + Assert.False(vm.Seats[1].IsHidden); + Assert.Empty(plans.GetById(plan.Id)!.HiddenSeats); + } + + [Fact] + public void ToggleSeatHidden_BelegterPlatz_TutNichts() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "A" }; + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = groupId, StudentId = student.Id }, + ]); + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1, + Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }], + }; + var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]), + memberships, new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + vm.IsEditMode = true; + + vm.ToggleSeatHidden(vm.Seats[0]); + + Assert.False(vm.Seats[0].IsHidden); + } + + /// Ein ausgeblendeter Platz darf nie ein Ziel für Drag&Drop/Zuordnung sein - sonst hätte man + /// gleichzeitig "kein Tisch hier" und einen dort sitzenden Schüler. + [Fact] + public void AssignStudent_AufAusgeblendetenPlatz_WirdIgnoriert() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "A" }; + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = groupId, StudentId = student.Id }, + ]); + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1, + HiddenSeats = [new HiddenSeat { Row = 0, Column = 0 }], + }; + var plans = new FakeSeatingPlans([plan]); + var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([student]), memberships, + new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + vm.IsEditMode = true; + + vm.AssignStudent(vm.StudentOptions.Single(o => o.StudentId == student.Id), vm.Seats[0]); + + Assert.False(vm.Seats[0].IsOccupied); + Assert.Empty(plans.GetById(plan.Id)!.Assignments); + } + + [Fact] + public void MoveSeat_AufAusgeblendetenPlatz_WirdIgnoriert() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "A" }; + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = groupId, StudentId = student.Id }, + ]); + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2, + HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }], + Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }], + }; + var plans = new FakeSeatingPlans([plan]); + var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([student]), memberships, + new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + vm.IsEditMode = true; + + vm.MoveSeat(vm.Seats[0], vm.Seats[1]); + + Assert.Equal(student.Id, vm.Seats[0].SelectedOption.StudentId); + Assert.False(vm.Seats[1].IsOccupied); + } + + [Fact] + public void ShowSeatUndCanToggleHidden_HaengenVonBearbeitungsmodusUndBelegungAb() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "A" }; + var memberships = new FakeMemberships([ + new GroupMembership { GroupId = groupId, StudentId = student.Id }, + ]); + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2, + HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }], + Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }], + }; + var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]), + memberships, new FakeSessions([]), new FakeEntries(), new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + + // Ansichtsmodus: der ausgeblendete Platz wird nicht gerendert, belegte Plätze können + // ohnehin nicht ausgeblendet werden. + Assert.False(vm.Seats[1].ShowSeat); + Assert.False(vm.Seats[0].CanToggleHidden); + Assert.False(vm.Seats[1].CanToggleHidden); + + vm.IsEditMode = true; + + // Bearbeitungsmodus: der ausgeblendete Platz bleibt sichtbar (abgeblendet), um ihn wieder + // einblenden zu können - der belegte Platz kann weiterhin nicht ausgeblendet werden. + Assert.True(vm.Seats[1].ShowSeat); + Assert.False(vm.Seats[0].CanToggleHidden); + Assert.True(vm.Seats[1].CanToggleHidden); + } + [Fact] public void Ansichtsmodus_VerhindertVerschiebenUndBlendetBearbeitungsbefehleAus() { diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index a25c2bf..8f5493b 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -167,6 +167,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1)) .Select(i => i < savedGapWidths.Count ? savedGapWidths[i] : 0) .ToArray(); + var hiddenSeats = plan.HiddenSeats ?? []; for (var row = 0; row < plan.Rows; row++) for (var column = 0; column < plan.Columns; column++) { @@ -175,8 +176,9 @@ public partial class SeatingPlanTabViewModel : ObservableObject ? StudentSeatOption.Empty : StudentOptions.FirstOrDefault(o => o.StudentId == assignment.StudentId) ?? StudentSeatOption.Empty; + var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column); Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, - CanEditLayout, ToggleSituationTag, IsEditable)); + CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden)); } UpdateAssignmentSummary(); RefreshSeatLessonData(); @@ -275,7 +277,8 @@ public partial class SeatingPlanTabViewModel : ObservableObject public void MoveSeat(SeatCellViewModel source, SeatCellViewModel target) { - if (!CanEditLayout || source == target || !source.SelectedOption.StudentId.HasValue) return; + if (!CanEditLayout || source == target || target.IsHidden + || !source.SelectedOption.StudentId.HasValue) return; var targetOption = target.SelectedOption; target.SetSelectionSilently(source.SelectedOption); source.SetSelectionSilently(targetOption); @@ -284,7 +287,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject public void AssignStudent(StudentSeatOption student, SeatCellViewModel target) { - if (!CanEditLayout || !student.StudentId.HasValue) return; + if (!CanEditLayout || target.IsHidden || !student.StudentId.HasValue) return; foreach (var other in Seats.Where(s => s != target && s.SelectedOption.StudentId == student.StudentId)) other.SetSelectionSilently(StudentSeatOption.Empty); target.SetSelectionSilently(student); @@ -298,6 +301,26 @@ public partial class SeatingPlanTabViewModel : ObservableObject SaveSeatAssignments(); } + /// Blendet einen leeren Platz aus/ein, für den im Raum physisch kein Tisch steht + /// (z. B. eine Reihe mit einer Lücke). Nur für leere Plätze möglich, siehe + /// SeatCellViewModel.CanToggleHidden. + public void ToggleSeatHidden(SeatCellViewModel seat) + { + if (!CanEditLayout || seat.IsOccupied) return; + seat.IsHidden = !seat.IsHidden; + SaveHiddenSeats(); + } + + private void SaveHiddenSeats() + { + if (_currentPlan is null) return; + _currentPlan.HiddenSeats = Seats + .Where(s => s.IsHidden) + .Select(s => new HiddenSeat { Row = s.Row, Column = s.Column }) + .ToList(); + _plans.Save(_currentPlan); + } + private void SaveSeatAssignments() { if (_currentPlan is null) return; @@ -405,6 +428,7 @@ public partial class SeatCellViewModel : ObservableObject private readonly Action _onChanged; private bool _suppressChange; private readonly Action _toggleSituationTag; + private readonly Action _toggleHidden; [ObservableProperty] private StudentSeatOption _selectedOption; [ObservableProperty] private bool _isDropTarget; @@ -422,11 +446,28 @@ public partial class SeatCellViewModel : ObservableObject public bool ShowLessonOverview => IsOccupied && !CanEdit; [ObservableProperty] private bool _canRecordLesson; public bool IsOccupied => SelectedOption.StudentId.HasValue; - public string StudentName => IsOccupied ? SelectedOption.DisplayName : "Freier Platz"; + public string StudentName => IsOccupied ? SelectedOption.DisplayName : IsHidden ? "Kein Tisch" : "Freier Platz"; + /// Kein Tisch an dieser Position im Raum vorhanden (siehe SeatingPlan.HiddenSeats). + /// Bleibt außerhalb des Bearbeitungsmodus Teil des Rasters (für die Spaltenausrichtung), wird + /// dort aber nicht gerendert - siehe ShowSeat. + [ObservableProperty] private bool _isHidden; + /// Im Ansichtsmodus werden ausgeblendete Plätze nicht gerendert; im Bearbeitungsmodus + /// bleiben sie sichtbar (abgeblendet), damit sie wieder eingeblendet werden können. + public bool ShowSeat => !IsHidden || CanEdit; + /// Nur leere Plätze können ausgeblendet werden - ein belegter Platz müsste sonst + /// erst geräumt werden, was beim bloßen Ausblenden überraschend wäre. + public bool CanToggleHidden => CanEdit && !IsOccupied; + public string HiddenToggleLabel => IsHidden ? "Tisch einblenden" : "Kein Tisch hier"; + /// Kombiniert LessonOpacity (Abwesenheits-Abblendung) mit dem Ausblenden-Zustand - + /// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal + /// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss + /// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen. + public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity; public SeatCellViewModel(int row, int column, ObservableCollection options, StudentSeatOption selectedOption, Action onChanged, bool canEdit, - Action? toggleSituationTag = null, bool canRecordLesson = false) + Action? toggleSituationTag = null, bool canRecordLesson = false, + bool isHidden = false, Action? toggleHidden = null) { Row = row; Column = column; @@ -436,6 +477,8 @@ public partial class SeatCellViewModel : ObservableObject _canEdit = canEdit; _toggleSituationTag = toggleSituationTag ?? ((_, _) => { }); _canRecordLesson = canRecordLesson; + _isHidden = isHidden; + _toggleHidden = toggleHidden ?? (_ => { }); foreach (var tag in SituationTagChoice.DefaultTags) SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value))); } @@ -445,10 +488,29 @@ public partial class SeatCellViewModel : ObservableObject OnPropertyChanged(nameof(IsOccupied)); OnPropertyChanged(nameof(StudentName)); OnPropertyChanged(nameof(ShowLessonOverview)); + OnPropertyChanged(nameof(CanToggleHidden)); if (!_suppressChange) _onChanged(this); } - partial void OnCanEditChanged(bool value) => OnPropertyChanged(nameof(ShowLessonOverview)); + partial void OnCanEditChanged(bool value) + { + OnPropertyChanged(nameof(ShowLessonOverview)); + OnPropertyChanged(nameof(ShowSeat)); + OnPropertyChanged(nameof(CanToggleHidden)); + } + + partial void OnIsHiddenChanged(bool value) + { + OnPropertyChanged(nameof(ShowSeat)); + OnPropertyChanged(nameof(StudentName)); + OnPropertyChanged(nameof(DisplayOpacity)); + OnPropertyChanged(nameof(HiddenToggleLabel)); + } + + partial void OnLessonOpacityChanged(double value) => OnPropertyChanged(nameof(DisplayOpacity)); + + [RelayCommand] + private void ToggleHidden() => _toggleHidden(this); public void SetSelectionSilently(StudentSeatOption option) { diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml index f927101..384e525 100644 --- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml @@ -18,6 +18,11 @@ + + Tapped="OnSeatTapped" Opacity="{Binding DisplayOpacity}"> @@ -126,6 +133,9 @@ +