feat: unbenutzte Sitzplätze ausblenden können

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:06:26 +02:00
co-authored by Claude Sonnet 5
parent 391628ae36
commit c5a1157a7d
8 changed files with 334 additions and 8 deletions
+13
View File
@@ -23,6 +23,13 @@ public class SeatingPlan
/// </summary>
public List<double> ColumnGapWidths { get; set; } = [];
public List<SeatAssignment> Assignments { get; set; } = [];
/// <summary>
/// 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.
/// </summary>
public List<HiddenSeat> 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; }
}
+52
View File
@@ -303,6 +303,58 @@ public sealed class RepositoryTests
Assert.Throws<InvalidOperationException>(() => 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()
{
@@ -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);
@@ -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()
{
@@ -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();
}
/// <summary>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.</summary>
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<SeatCellViewModel> _onChanged;
private bool _suppressChange;
private readonly Action<SeatCellViewModel, string> _toggleSituationTag;
private readonly Action<SeatCellViewModel> _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";
/// <summary>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.</summary>
[ObservableProperty] private bool _isHidden;
/// <summary>Im Ansichtsmodus werden ausgeblendete Plätze nicht gerendert; im Bearbeitungsmodus
/// bleiben sie sichtbar (abgeblendet), damit sie wieder eingeblendet werden können.</summary>
public bool ShowSeat => !IsHidden || CanEdit;
/// <summary>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.</summary>
public bool CanToggleHidden => CanEdit && !IsOccupied;
public string HiddenToggleLabel => IsHidden ? "Tisch einblenden" : "Kein Tisch hier";
/// <summary>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.</summary>
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
public SeatCellViewModel(int row, int column, ObservableCollection<StudentSeatOption> options,
StudentSeatOption selectedOption, Action<SeatCellViewModel> onChanged, bool canEdit,
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false)
Action<SeatCellViewModel, string>? toggleSituationTag = null, bool canRecordLesson = false,
bool isHidden = false, Action<SeatCellViewModel>? 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)
{
@@ -18,6 +18,11 @@
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="Border.seat.hidden">
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Background" Value="Transparent"/>
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="260,*">
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
@@ -108,6 +113,8 @@
<DataTemplate x:DataType="vm:SeatCellViewModel">
<Border Classes="seat" Classes.occupied="{Binding IsOccupied}"
Classes.droptarget="{Binding IsDropTarget}"
Classes.hidden="{Binding IsHidden}"
IsVisible="{Binding ShowSeat}"
Width="190" MinHeight="76" Margin="5" Padding="10"
CornerRadius="7"
DragDrop.AllowDrop="True"
@@ -118,7 +125,7 @@
PointerPressed="OnDragSourcePressed"
PointerMoved="OnDragSourceMoved"
PointerReleased="OnDragSourceReleased"
Tapped="OnSeatTapped" Opacity="{Binding LessonOpacity}">
Tapped="OnSeatTapped" Opacity="{Binding DisplayOpacity}">
<StackPanel Spacing="5">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding PositionLabel}" FontSize="10" Opacity="0.5"/>
@@ -126,6 +133,9 @@
</Grid>
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Button Content="{Binding HiddenToggleLabel}"
Command="{Binding ToggleHiddenCommand}" FontSize="9" Padding="6,2"
HorizontalAlignment="Center" IsVisible="{Binding CanToggleHidden}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="5"
IsVisible="{Binding ShowLessonOverview}">
<Border Background="#1976D2" CornerRadius="8" Padding="6,1"
@@ -134,7 +134,7 @@ public partial class SeatingPlanTabView : UserControl
}
private bool CanDropOn(SeatCellViewModel target) =>
DataContext is SeatingPlanTabViewModel { CanEditLayout: true }
DataContext is SeatingPlanTabViewModel { CanEditLayout: true } && !target.IsHidden
&& ((_draggedSeat is not null && _draggedSeat != target) || _draggedStudent?.StudentId is not null);
private void OnUnassignedDragOver(object? sender, DragEventArgs e) =>
+23
View File
@@ -1333,6 +1333,29 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
Erzeugung passiert erst über `EnsureTodaySession()` bei der ersten tatsächlichen Aktion
(Sitzplatz-Bewertung oder Situations-Tag).
**Nachtrag (Feature-Ergänzung, Nutzerwunsch): unbenutzte Plätze ausblenden.** Manche Räume
sind unregelmäßig bestückt (z. B. eine Klasse mit 3 Reihen, in zwei davon fehlt ein
Einzeltisch) — das feste `Rows × Columns`-Raster kennt aber keine per-Reihe abweichende
Spaltenzahl. Statt einer negativen "Schüler-ID" als Sonderfall (das war der ursprünglich
vorgeschlagene Ansatz, scheidet aber aus, da `SeatAssignment.StudentId` ein `Guid` ist, kein
vorzeichenbehafteter Zahlentyp) gibt es jetzt ein eigenes Feld `SeatingPlan.HiddenSeats`
(`List<HiddenSeat { Row, Column }>`, analog zu `SeatAssignment`) für Plätze ohne physischen
Tisch. Ein ausgeblendeter Platz bleibt Teil des Rasters (für die Spalten-/Reihenausrichtung
der `SeatingPlanPanel`-Layoutmathematik), wird im Ansichtsmodus aber nicht gerendert
(`SeatCellViewModel.ShowSeat`); im Bearbeitungsmodus bleibt er sichtbar (abgeblendet), mit
einem Umschalt-Button "Kein Tisch hier" / "Tisch einblenden" direkt auf dem Platz. Nur leere
Plätze lassen sich ausblenden (`CanToggleHidden`); Zuweisen/Verschieben eines Schülers auf
einen ausgeblendeten Platz wird sowohl im ViewModel (`AssignStudent`/`MoveSeat`) als auch im
Drag&Drop-Ziel-Check der View verhindert. `SeatingPlanRepository.Save` filtert
`HiddenSeats` zusätzlich auf das gültige Raster und blendet einen Platz automatisch wieder
ein, falls er (z. B. durch einen älteren, noch nicht aktualisierten Client per Sync)
trotzdem belegt wird — die Zuordnung eines Schülers ist wertvoller als das Ausblenden und
geht nie verloren. Keine Änderungen an `EventApplier`/`SyncEngine` nötig, `SeatingPlan` wird
bereits als Ganzes synchronisiert. Neue Tests: `RepositoryTests`
(Rasterfilterung, automatisches Wiedereinblenden bei Belegung), `SeatingPlanViewModelTests`
(Laden, Umschalten inkl. Persistieren, Sperre für belegte Plätze, verhinderte Zuweisung auf
ausgeblendete Plätze, `ShowSeat`/`CanToggleHidden` je nach Modus).
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,