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 readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _participation; private readonly IParticipationAspectRepository _aspects; private readonly IDocumentationRepository? _documentation; [ObservableProperty] private bool _isTeachingMode; [ObservableProperty] private string _quickMode = ""; public bool IsQuickMode => QuickMode.Length > 0; public string QuickModeDisplay => QuickMode switch { "Attendance" => "Anwesenheit kontrollieren · Fehlend = Entschuldigung offen", "Homework" => "Hausaufgaben kontrollieren", _ => "Klicken: bewerten" }; [RelayCommand] private void CheckAttendance() => QuickMode = "Attendance"; [RelayCommand] private void CheckHomework() => QuickMode = "Homework"; [RelayCommand] private void EndQuickCheck() => QuickMode = ""; partial void OnQuickModeChanged(string value) { if (value.Length > 0) IsEditMode = false; foreach (var seat in Seats) seat.QuickMode = value; OnPropertyChanged(nameof(IsQuickMode)); OnPropertyChanged(nameof(QuickModeDisplay)); } private void SaveQuickStatus(SeatCellViewModel seat, bool positive) { if (!IsEditable || !IsQuickMode || seat.SelectedOption.StudentId is not Guid studentId) return; var session = EnsureTodaySession(); if (session is null) return; // Always merge with the latest entry, so quick checks preserve ratings and counters. var entry = _participation.GetBySessionAndStudent(session.Id, studentId) ?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId }; if (QuickMode == "Attendance") entry.Attendance = positive ? AttendanceStatus.Present : AttendanceStatus.ExcusePending; else { entry.Homework = positive ? HomeworkStatus.Completed : HomeworkStatus.MissingOpen; entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework); } _participation.Save(entry); RefreshSeatLessonData(); OnAssessmentChanged?.Invoke(); } 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 = ""; [ObservableProperty] private bool _isBoardAtTop = true; [ObservableProperty] private bool _isBoardAtBottom; [ObservableProperty] private IReadOnlyList _columnGapWidths = []; [ObservableProperty] private bool _isEditMode; [ObservableProperty] private ParticipationSessionOption? _selectedSession; public ObservableCollection Plans { get; } = []; public ObservableCollection Seats { get; } = []; public ObservableCollection StudentOptions { get; } = []; public ObservableCollection UnassignedStudents { get; } = []; public ObservableCollection TodaySessions { get; } = []; public bool HasPlans => Plans.Count > 0; public bool HasSelectedPlan => _currentPlan is not null; public bool IsEditable => !_isReadOnly; public bool CanEditLayout => IsEditable && IsEditMode; public Func>? OnEditPlan { get; set; } public Func>? OnConfirmDelete { get; set; } public Func? OnAssessStudent { get; set; } public Action? OnAssessmentChanged { get; set; } public Action? OnDocumentationChanged { get; set; } public SeatingPlanTabViewModel(ISeatingPlanRepository plans, IStudentRepository students, IGroupMembershipRepository memberships, IParticipationSessionRepository sessions, IParticipationRepository participation, IParticipationAspectRepository aspects, IDocumentationRepository? documentation = null) { _plans = plans; _students = students; _memberships = memberships; _sessions = sessions; _participation = participation; _aspects = aspects; _documentation = documentation; } public SeatingPlanDialogViewModel CreateDialogViewModel(SeatingPlan? plan) => new(_plans, _groupId, plan); public void Initialize(Guid groupId, bool isReadOnly) { _groupId = groupId; _isReadOnly = isReadOnly; IsEditMode = false; LoadTodaySessions(); LoadStudentOptions(); ReloadPlans(); OnPropertyChanged(nameof(IsEditable)); NotifyCommands(); } /// Legt bewusst KEINE Sitzung an, nur weil der Tab geöffnet wird — Initialize() läuft für /// jede Gruppe schon beim bloßen Navigieren zur Kursübersicht (GroupDetailViewModel.LoadGroup), /// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Eine Sitzung entsteht erst /// bei der ersten tatsächlichen Bewertung/Markierung, siehe EnsureTodaySession(). private void LoadTodaySessions() { TodaySessions.Clear(); var today = DateOnly.FromDateTime(DateTime.Today); var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today) .OrderBy(s => s.CreatedAt).ToList(); foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session)); SelectedSession = TodaySessions.LastOrDefault(); } /// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen /// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/ /// ToggleSituationTag), nicht schon beim Öffnen des Tabs. /// /// Prüft dabei bewusst gegen das Repository und nicht nur gegen das lokal geladene /// / : Der Unterrichtsmodus /// () läuft auf einer eigenen, per DI frisch /// aufgelösten -Instanz (siehe TimetableView.ShowTeachingMode), /// getrennt von der Instanz, die im Sitzplan-Tab der Kursübersicht hängt. Eine dort bereits /// heute angelegte (ggf. verknüpfte) Sitzung ist dieser Instanz also unbekannt, solange sie nur /// im lokalen Feld nachschaut — das führte dazu, dass eine zweite, unverknüpfte "Sitzplan"- /// Sitzung für denselben Tag entstand, sobald im echten Sitzplan-Tab bewertet wurde. private ParticipationSessionOption? EnsureTodaySession() { if (SelectedSession is not null) return SelectedSession; if (!IsEditable) return null; var today = DateOnly.FromDateTime(DateTime.Today); var existing = _sessions.GetByGroup(_groupId).FirstOrDefault(s => s.Date == today); if (existing is not null) { var found = TodaySessions.FirstOrDefault(o => o.Id == existing.Id); if (found is null) { found = new ParticipationSessionOption(existing); TodaySessions.Add(found); } SelectedSession = found; return found; } var created = new ParticipationSession { GroupId = _groupId, Date = today, Comment = "Sitzplan", }; _sessions.Save(created); var option = new ParticipationSessionOption(created); TodaySessions.Add(option); SelectedSession = option; return option; } /// /// Für den Unterrichtsmodus (14.x): wählt eine bereits mit dieser Stunde verknüpfte Sitzung /// aus (angelegt z.B. über PlanningTabViewModel.CreateParticipationSession, 3.3.1) oder legt /// bei Bedarf eine neue, EXPLIZIT verknüpfte an (). /// Anders als (anonyme "Sitzplan"-Sitzung, erst bei der /// ersten tatsächlichen Aktion) ist das hier bewusst sofort beim Start des Unterrichtsmodus /// erlaubt: welche Stunde gemeint ist, steht durch die explizite Auswahl der Lehrkraft /// (Klick auf "Unterrichtsmodus starten" für genau diese Stunde) bereits unzweideutig fest - /// keine Geistersitzungs-Gefahr wie beim bloßen Öffnen eines Tabs. /// /// Fällt bewusst auf JEDE an diesem Tag bereits bestehende Sitzung zurück, nicht nur auf eine /// exakt mit `lesson.Id` verknüpfte (Nutzer-Feedback): kommt neben einer Doppelstunde noch eine /// dritte Stunde am selben Tag hinzu (z.B. Vertretung, eigene `Lesson` mit eigener Id), soll /// keine zweite Mitarbeitssitzung für denselben Tag entstehen — die Lehrkraft passt stattdessen /// die Einschätzung der bereits bestehenden Sitzung an. Das entspricht dem Verhalten von /// , das schon immer pro Tag statt pro Stunde arbeitet. /// public void SelectOrCreateSessionForLesson(Lesson lesson) { var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id) ?? TodaySessions.FirstOrDefault(s => s.Date == lesson.Date); if (existing is not null) { SelectedSession = existing; return; } if (!IsEditable) return; var created = new ParticipationSession { GroupId = _groupId, Date = lesson.Date, LessonId = lesson.Id, Comment = lesson.Topic, }; _sessions.Save(created); var option = new ParticipationSessionOption(created); TodaySessions.Add(option); SelectedSession = option; } partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData(); 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) { UnassignedStudents.Clear(); PlanColumns = 1; PlanTitle = ""; PlanSubtitle = ""; AssignmentSummary = ""; IsBoardAtTop = true; IsBoardAtBottom = false; ColumnGapWidths = []; } 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"; IsBoardAtBottom = plan.IsBoardAtBottom; IsBoardAtTop = !plan.IsBoardAtBottom; var savedGapWidths = plan.ColumnGapWidths ?? []; 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++) { 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; var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column); Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation) { QuickMode = QuickMode, OnQuickStatus = SaveQuickStatus, OnQuickSpecial = AssessStudent }); } UpdateAssignmentSummary(); RefreshSeatLessonData(); } OnPropertyChanged(nameof(HasSelectedPlan)); NotifyCommands(); } /// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis, siehe /// UntisSyncService-Kommentar): der Unterrichtsmodus bewertet über eine zweite, unabhängige /// ParticipationTabViewModel-Instanz (Schnellbewertungs-Dialoge, siehe TeachingModeWindow) — /// diese Instanz hier bekommt davon nichts automatisch mit und muss nach jedem Dialog explizit /// neu aus dem Repository laden, damit die Sitzplatz-Badges nicht veraltet bleiben. public void ReloadSeatBadgesFromRepository() => RefreshSeatLessonData(); private void RefreshSeatLessonData() { if (SelectedSession is null) { foreach (var seat in Seats) seat.SetLessonData(null, []); return; } var entries = _participation.GetBySession(SelectedSession.Id) .ToDictionary(e => e.StudentId); foreach (var seat in Seats) { if (seat.SelectedOption.StudentId is not Guid studentId) { seat.SetLessonData(null, []); continue; } entries.TryGetValue(studentId, out var entry); var tags = _documentation?.GetByStudent(studentId) .FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id)?.Tags ?? []; seat.SetLessonData(entry, tags); } } /// Strichliste im Sitzplan (Nutzer-Feedback): "drangekommen" ist immer auch eine Meldung und /// erhöht daher beide Zähler — es gibt kein "drangekommen, ohne sich gemeldet zu haben". private void TallyParticipation(SeatCellViewModel seat, bool calledOn) { if (!IsEditable || seat.SelectedOption.StudentId is not Guid studentId) return; var session = EnsureTodaySession(); if (session is null) return; var entry = _participation.GetBySessionAndStudent(session.Id, studentId) ?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId }; entry.RaisedHandCount++; if (calledOn) entry.CalledOnCount++; _participation.Save(entry); RefreshSeatLessonData(); } private void ToggleSituationTag(SeatCellViewModel seat, string tag) { if (!IsEditable || _documentation is null || seat.SelectedOption.StudentId is not Guid studentId) return; var session = EnsureTodaySession(); if (session is null) return; var draft = _documentation.GetByStudent(studentId) .FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == session.Id); if (draft is null) { draft = new Documentation { StudentId = studentId, GroupId = _groupId, ParticipationSessionId = session.Id, LessonId = session.LessonId, Type = DocumentationType.Incident, Date = session.Date, Title = tag, IsDraft = true, Tags = [tag], }; } else if (draft.Tags.Contains(tag)) { draft.Tags.Remove(tag); if (draft.Tags.Count == 0) { _documentation.Delete(draft.Id); RefreshSeatLessonData(); OnDocumentationChanged?.Invoke(); return; } draft.Title = draft.Tags[0]; } else draft.Tags.Add(tag); draft.UpdatedAt = DateTime.UtcNow; _documentation.Save(draft); RefreshSeatLessonData(); OnDocumentationChanged?.Invoke(); } private void OnSeatChanged(SeatCellViewModel changed) { if (_currentPlan is null || !CanEditLayout) 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); } SaveSeatAssignments(); } private void UpdateAssignmentSummary() { var assigned = Seats.Count(s => s.SelectedOption.StudentId.HasValue); var total = StudentOptions.Count - 1; AssignmentSummary = $"{assigned} von {total} Schülern zugeordnet"; var assignedIds = Seats.Where(s => s.SelectedOption.StudentId.HasValue) .Select(s => s.SelectedOption.StudentId!.Value).ToHashSet(); UnassignedStudents.Clear(); foreach (var option in StudentOptions.Where(o => o.StudentId.HasValue && !assignedIds.Contains(o.StudentId.Value))) UnassignedStudents.Add(option); } public void MoveSeat(SeatCellViewModel source, SeatCellViewModel target) { if (!CanEditLayout || source == target || target.IsHidden || !source.SelectedOption.StudentId.HasValue) return; var targetOption = target.SelectedOption; target.SetSelectionSilently(source.SelectedOption); source.SetSelectionSilently(targetOption); SaveSeatAssignments(); } public void AssignStudent(StudentSeatOption student, SeatCellViewModel target) { 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); SaveSeatAssignments(); } public void ClearSeat(SeatCellViewModel seat) { if (!CanEditLayout || !seat.SelectedOption.StudentId.HasValue) return; seat.SetSelectionSilently(StudentSeatOption.Empty); 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; _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(); } public async Task AssessStudent(SeatCellViewModel seat) { if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return; var session = EnsureTodaySession(); var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects, _groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable, session?.Id); await OnAssessStudent(assessment); RefreshSeatLessonData(); OnAssessmentChanged?.Invoke(); } [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(); } /// /// Klausur-Sitzplan mischen (14, Nutzer-Idee): erzeugt aus dem aktuell gewählten Plan einen /// neuen, unabhängigen Plan mit gleichem Raster/Raum, aber zufällig vertauschten Insassen der /// belegten Plätze (Fisher-Yates via ) — die Platzkoordinaten selbst /// bleiben unverändert, nur wer wo sitzt wird neu gewürfelt. Der ursprüngliche Plan bleibt /// unangetastet erhalten, damit er bei Bedarf für den nächsten regulären Unterricht weiter /// genutzt werden kann. /// [RelayCommand(CanExecute = nameof(CanEditSelected))] private void ShuffleSeats() { if (_currentPlan is null) return; var studentIds = _currentPlan.Assignments.Select(a => a.StudentId).ToArray(); Random.Shared.Shuffle(studentIds); var shuffled = new SeatingPlan { GroupId = _currentPlan.GroupId, Name = $"{_currentPlan.Name} (Klausur gemischt {DateTime.Now:dd.MM. HH:mm})", Room = _currentPlan.Room, Rows = _currentPlan.Rows, Columns = _currentPlan.Columns, ColumnGapWidths = [.. _currentPlan.ColumnGapWidths], IsBoardAtBottom = _currentPlan.IsBoardAtBottom, HiddenSeats = _currentPlan.HiddenSeats.Select(h => new HiddenSeat { Row = h.Row, Column = h.Column }).ToList(), Assignments = _currentPlan.Assignments .Select((a, i) => new SeatAssignment { Row = a.Row, Column = a.Column, StudentId = studentIds[i] }) .ToList(), }; _plans.Save(shuffled); ReloadPlans(shuffled.Id); } partial void OnIsEditModeChanged(bool value) { if (value) QuickMode = ""; OnPropertyChanged(nameof(CanEditLayout)); foreach (var seat in Seats) { seat.CanEdit = CanEditLayout; seat.CanRecordLesson = IsEditable && !value; } NotifyCommands(); } private bool CanEdit() => IsEditable && (IsEditMode || !HasPlans); private bool CanEditSelected() => CanEditLayout && _currentPlan is not null; private void NotifyCommands() { AddPlanCommand.NotifyCanExecuteChanged(); EditPlanCommand.NotifyCanExecuteChanged(); DeletePlanCommand.NotifyCanExecuteChanged(); ShuffleSeatsCommand.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 sealed class ParticipationSessionOption(ParticipationSession session) { public Guid Id => session.Id; public Guid? LessonId => session.LessonId; public DateOnly Date => session.Date; public string DisplayName => string.IsNullOrWhiteSpace(session.Comment) ? $"{session.Date:dd.MM.yyyy}" : $"{session.Date:dd.MM.yyyy} · {session.Comment}"; } public partial class SeatCellViewModel : ObservableObject { [ObservableProperty] private string _quickMode = ""; public bool ShowQuickCheck => QuickMode.Length > 0 && IsOccupied && CanRecordLesson; public bool ShowNormalActions => ShowLessonOverview && QuickMode.Length == 0; public bool ShowSituationActions => ShowNormalActions && CanRecordLesson; public string QuickPositiveLabel => QuickMode == "Attendance" ? "Anwesend" : "Gemacht"; public string QuickNegativeLabel => QuickMode == "Attendance" ? "Fehlend" : "Fehlt"; public Action? OnQuickStatus { get; init; } public Func? OnQuickSpecial { get; init; } [RelayCommand] private void QuickPositive() => OnQuickStatus?.Invoke(this, true); [RelayCommand] private void QuickNegative() => OnQuickStatus?.Invoke(this, false); [RelayCommand] private Task QuickSpecial() => OnQuickSpecial?.Invoke(this) ?? Task.CompletedTask; partial void OnQuickModeChanged(string value) { OnPropertyChanged(nameof(ShowQuickCheck)); OnPropertyChanged(nameof(ShowNormalActions)); OnPropertyChanged(nameof(ShowSituationActions)); OnPropertyChanged(nameof(QuickPositiveLabel)); OnPropertyChanged(nameof(QuickNegativeLabel)); OnPropertyChanged(nameof(DisplayOpacity)); } partial void OnCanRecordLessonChanged(bool value) { OnPropertyChanged(nameof(ShowQuickCheck)); OnPropertyChanged(nameof(ShowSituationActions)); } private readonly Action _onChanged; private bool _suppressChange; private readonly Action _toggleSituationTag; private readonly Action _toggleHidden; [ObservableProperty] private StudentSeatOption _selectedOption; [ObservableProperty] private bool _isDropTarget; public int Row { get; } public int Column { get; } public string PositionLabel => $"Reihe {Row + 1} · Platz {Column + 1}"; public ObservableCollection Options { get; } [ObservableProperty] private bool _canEdit; [ObservableProperty] private double _lessonOpacity = 1.0; [ObservableProperty] private string _attendanceBadge = ""; [ObservableProperty] private string _homeworkBadge = ""; [ObservableProperty] private string _dayHighlightBadge = ""; [ObservableProperty] private int _raisedHandCount; [ObservableProperty] private int _calledOnCount; public ObservableCollection SituationTags { get; } = []; public bool HasAttendanceBadge => AttendanceBadge.Length > 0; public bool HasHomeworkBadge => HomeworkBadge.Length > 0; public bool HasDayHighlightBadge => DayHighlightBadge.Length > 0; public bool ShowLessonOverview => IsOccupied && !CanEdit; [ObservableProperty] private bool _canRecordLesson; public bool IsOccupied => SelectedOption.StudentId.HasValue; 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 : ShowQuickCheck ? 1 : LessonOpacity; private readonly Action _tally; public SeatCellViewModel(int row, int column, ObservableCollection options, StudentSeatOption selectedOption, Action onChanged, bool canEdit, Action? toggleSituationTag = null, bool canRecordLesson = false, bool isHidden = false, Action? toggleHidden = null, Action? tally = null) { Row = row; Column = column; Options = options; _selectedOption = selectedOption; _onChanged = onChanged; _canEdit = canEdit; _toggleSituationTag = toggleSituationTag ?? ((_, _) => { }); _canRecordLesson = canRecordLesson; _isHidden = isHidden; _toggleHidden = toggleHidden ?? (_ => { }); _tally = tally ?? ((_, _) => { }); foreach (var tag in SituationTagChoice.DefaultTags) SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value))); } /// Strichliste im Sitzplan (Nutzer-Feedback): schnelles Mitzählen während des Unterrichts, /// ohne dafür den vollen Sitzplatz-Bewertungsdialog öffnen zu müssen — bewusst nur ein /// Hochzählen ohne Korrekturmöglichkeit hier direkt auf der Kachel; die Rohwerte bleiben über /// den Bewertungsdialog einsehbar und fließen dort als Quantitäts-Vorschlag ein. [RelayCommand] private void TallyRaisedHand() => _tally(this, false); [RelayCommand] private void TallyCalledOn() => _tally(this, true); partial void OnSelectedOptionChanged(StudentSeatOption value) { OnPropertyChanged(nameof(ShowQuickCheck)); OnPropertyChanged(nameof(ShowNormalActions)); OnPropertyChanged(nameof(ShowSituationActions)); OnPropertyChanged(nameof(IsOccupied)); OnPropertyChanged(nameof(StudentName)); OnPropertyChanged(nameof(ShowLessonOverview)); OnPropertyChanged(nameof(CanToggleHidden)); if (!_suppressChange) _onChanged(this); } partial void OnCanEditChanged(bool value) { OnPropertyChanged(nameof(ShowNormalActions)); OnPropertyChanged(nameof(ShowSituationActions)); 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) { _suppressChange = true; SelectedOption = option; _suppressChange = false; } public void SetLessonData(ParticipationEntry? entry, IEnumerable tags) { var attendance = entry?.Attendance; AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance); HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry)); DayHighlightBadge = DayHighlightDisplay.Symbol(entry?.DayHighlight); RaisedHandCount = entry?.RaisedHandCount ?? 0; CalledOnCount = entry?.CalledOnCount ?? 0; LessonOpacity = attendance is not null and not AttendanceStatus.Present and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0; var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var choice in SituationTags) choice.IsSelected = selected.Contains(choice.Text); OnPropertyChanged(nameof(HasAttendanceBadge)); OnPropertyChanged(nameof(HasHomeworkBadge)); OnPropertyChanged(nameof(HasDayHighlightBadge)); } } public partial class SituationTagChoice(string text, bool isSelected, Action toggle) : ObservableObject { public static readonly string[] DefaultTags = [ "Mitarbeit verweigert", "Unterricht gestört", "Streit/Konflikt", "Langer Toilettengang", "Material vergessen", "Handynutzung", "Besonders hilfsbereit", "Sehr gute Mitarbeit", "Gespräch erforderlich", ]; public string Text { get; } = text; [ObservableProperty] private bool _isSelected = isSelected; [RelayCommand] private void Toggle() => toggle(Text); } public partial class SeatAssessmentViewModel : ObservableObject { private readonly IParticipationRepository _entries; private readonly ParticipationEntry? _entry; private readonly bool _canEdit; [ObservableProperty] private int _selectedAspectIndex; [ObservableProperty] private string _attendanceLabel = "Noch nicht kontrolliert"; [ObservableProperty] private string _homeworkLabel = "Keine Hausaufgabe aufgegeben"; [ObservableProperty] private string _dayHighlightLabel = "Keine Markierung"; public string StudentName { get; } public string SessionDisplay { get; } public bool CanEdit => _canEdit && _entry is not null; public string ReadOnlyHint => _entry is null ? "Für heute existiert keine Sitzung. In einer archivierten Gruppe kann keine neue angelegt werden." : "Archivierte Lerngruppe – Bewertung nur ansehen."; public ObservableCollection AspectRows { get; } = []; public ObservableCollection AttendanceChoices { get; } = []; public ObservableCollection HomeworkChoices { get; } = []; public ObservableCollection DayHighlightChoices { get; } = []; public SeatAssessmentViewModel(IParticipationSessionRepository sessions, IParticipationRepository entries, IParticipationAspectRepository aspects, Guid groupId, Guid studentId, string studentName, bool canEdit, Guid? sessionId = null) { _entries = entries; _canEdit = canEdit; StudentName = studentName; var today = DateOnly.FromDateTime(DateTime.Today); var session = sessionId.HasValue ? sessions.GetById(sessionId.Value) : null; session ??= sessions.GetByGroup(groupId).FirstOrDefault(s => s.Date == today); if (session is null && canEdit) { session = new ParticipationSession { GroupId = groupId, Date = today, Comment = "Sitzplan", }; sessions.Save(session); } SessionDisplay = session is null ? "Keine Sitzung für heute" : $"{session.Date:dd.MM.yyyy} · {session.Comment}"; _entry = session is null ? null : entries.GetBySessionAndStudent(session.Id, studentId) ?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId }; var aspectDefinitions = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId)).ToList(); if (aspectDefinitions.Count == 0) aspectDefinitions = DefaultParticipationAspects.All.Select(a => new ParticipationAspect { Key = a.Key, Label = a.Label, ValueType = a.ValueType, MaxPoints = a.MaxPoints, }).ToList(); foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i))) { var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value; // Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback): nur für den // eingebauten "quantity"-Aspekt, siehe ParticipationCountSuggestion. var raisedHandCount = aspect.Key == "quantity" ? _entry?.RaisedHandCount : null; AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating, raisedHandCount)); } BuildAttendanceChoices(); BuildHomeworkChoices(); BuildDayHighlightChoices(); RefreshStatusChoices(); SelectAspect(0); } private void BuildAttendanceChoices() { AttendanceChoices.Add(new("✓", "Anwesend", "Strg+1", AttendanceStatus.Present, SetAttendance)); AttendanceChoices.Add(new("?", "Entschuldigung offen", "Strg+2", AttendanceStatus.ExcusePending, SetAttendance)); AttendanceChoices.Add(new("⊘", "Entschuldigt", "Strg+5", AttendanceStatus.Excused, SetAttendance)); AttendanceChoices.Add(new("◇", "Schulveranstaltung", "Strg+7", AttendanceStatus.OtherSchoolEvent, SetAttendance)); AttendanceChoices.Add(new("✕", "Geschwänzt", "Strg+9", AttendanceStatus.Truant, SetAttendance)); AttendanceChoices.Add(new("!", "Unentschuldigt", "Strg+0", AttendanceStatus.Unexcused, SetAttendance)); AttendanceChoices.Add(new("V", "Verspätet", "", AttendanceStatus.Late, SetAttendance)); AttendanceChoices.Add(new("V!", "Erheblich verspätet", "", AttendanceStatus.SignificantlyLate, SetAttendance)); AttendanceChoices.Add(new("A", "Im Unterricht abgängig", "", AttendanceStatus.LeftDuringClass, SetAttendance)); AttendanceChoices.Add(new("L", "Lerninsel", "", AttendanceStatus.LearningIsland, SetAttendance)); AttendanceChoices.Add(new("S", "Suspendiert", "", AttendanceStatus.Suspended, SetAttendance)); AttendanceChoices.Add(new("·", "Nicht kontrolliert", "Strg+X", null, SetAttendance)); } private void BuildHomeworkChoices() { HomeworkChoices.Add(new("✓", "Gemacht", "⌥1", HomeworkStatus.Completed, SetHomework)); HomeworkChoices.Add(new("◐", "Teilweise", "⌥3", HomeworkStatus.PartiallyCompleted, SetHomework)); HomeworkChoices.Add(new("◕", "Rest nachgereicht", "⌥4", HomeworkStatus.PartialSubmittedLate, SetHomework)); HomeworkChoices.Add(new("◒", "Rest fehlt", "⌥5", HomeworkStatus.PartialMissingOverdue, SetHomework)); HomeworkChoices.Add(new("!", "Nicht gemacht", "⌥7", HomeworkStatus.MissingOpen, SetHomework)); HomeworkChoices.Add(new("↺", "Nachgereicht", "⌥8", HomeworkStatus.SubmittedLate, SetHomework)); HomeworkChoices.Add(new("✕", "Nicht nachgereicht", "⌥0", HomeworkStatus.MissingOverdue, SetHomework)); HomeworkChoices.Add(new("·", "Keine aufgegeben", "⌥X", null, SetHomework)); } private void BuildDayHighlightChoices() { DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Standout), DayHighlightDisplay.Label(DayHighlightKind.Standout), "⇧1", DayHighlightKind.Standout, SetDayHighlight)); DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Sleepy), DayHighlightDisplay.Label(DayHighlightKind.Sleepy), "⇧2", DayHighlightKind.Sleepy, SetDayHighlight)); DayHighlightChoices.Add(new(DayHighlightDisplay.Symbol(DayHighlightKind.Rough), DayHighlightDisplay.Label(DayHighlightKind.Rough), "⇧3", DayHighlightKind.Rough, SetDayHighlight)); DayHighlightChoices.Add(new("·", "Keine Markierung", "⇧X", null, SetDayHighlight)); } public void SelectAspect(int index) { if (index < 0 || index >= AspectRows.Count) return; SelectedAspectIndex = index; foreach (var row in AspectRows) row.IsActive = row.Index == index; } public void MoveAspect(int delta) { if (AspectRows.Count == 0) return; SelectAspect(Math.Clamp(SelectedAspectIndex + delta, 0, AspectRows.Count - 1)); } public void SetRatingByNumber(int number) { if (!CanEdit) return; var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex); if (row is null) return; if (row.ValueType == AspectValueType.Points) row.ApplyValue(Math.Clamp(number, 0, row.MaxPoints)); else { var steps = ParticipationRatingScale.Steps(row.ValueType); if (number >= 1 && number <= steps.Count) row.ApplyValue(steps[number - 1].Value); } } public void AdjustCurrentRating(int delta) { if (!CanEdit) return; var row = AspectRows.ElementAtOrDefault(SelectedAspectIndex); row?.Adjust(delta); } public void ClearCurrentRating() { if (!CanEdit) return; AspectRows.ElementAtOrDefault(SelectedAspectIndex)?.ApplyValue(null); } public void ApplyAttendanceShortcut(int? digit, bool clear) { if (!CanEdit) return; var status = clear ? null : digit switch { 1 => AttendanceStatus.Present, 2 => AttendanceStatus.ExcusePending, 5 => AttendanceStatus.Excused, 7 => AttendanceStatus.OtherSchoolEvent, 9 => AttendanceStatus.Truant, 0 => AttendanceStatus.Unexcused, _ => (AttendanceStatus?)null, }; if (clear || digit is 0 or 1 or 2 or 5 or 7 or 9) SetAttendance(status); } public void ApplyHomeworkShortcut(int? digit, bool clear) { if (!CanEdit) return; var status = clear ? null : digit switch { 1 => HomeworkStatus.Completed, 3 => HomeworkStatus.PartiallyCompleted, 4 => HomeworkStatus.PartialSubmittedLate, 5 => HomeworkStatus.PartialMissingOverdue, 7 => HomeworkStatus.MissingOpen, 8 => HomeworkStatus.SubmittedLate, 0 => HomeworkStatus.MissingOverdue, _ => (HomeworkStatus?)null, }; if (clear || digit is 0 or 1 or 3 or 4 or 5 or 7 or 8) SetHomework(status); } public void ApplyDayHighlightShortcut(int? digit, bool clear) { if (!CanEdit) return; var kind = clear ? null : digit switch { 1 => DayHighlightKind.Standout, 2 => DayHighlightKind.Sleepy, 3 => DayHighlightKind.Rough, _ => (DayHighlightKind?)null, }; if (clear || digit is 1 or 2 or 3) SetDayHighlight(kind); } private void ApplyRating(string key, int? value) { if (!CanEdit || _entry is null) return; var existing = _entry.Ratings.FirstOrDefault(r => r.Key == key); if (value is null) { if (existing is not null) _entry.Ratings.Remove(existing); } else if (existing is null) _entry.Ratings.Add(new AspectRating { Key = key, Value = value.Value }); else existing.Value = value.Value; _entries.Save(_entry); } private void SetAttendance(AttendanceStatus? status) { if (!CanEdit || _entry is null) return; _entry.Attendance = status; _entries.Save(_entry); RefreshStatusChoices(); } private void SetHomework(HomeworkStatus? status) { if (!CanEdit || _entry is null) return; _entry.Homework = status; _entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(status); _entries.Save(_entry); RefreshStatusChoices(); } private void SetDayHighlight(DayHighlightKind? kind) { if (!CanEdit || _entry is null) return; _entry.DayHighlight = kind; _entries.Save(_entry); RefreshStatusChoices(); } private void RefreshStatusChoices() { AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance); HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry)); DayHighlightLabel = DayHighlightDisplay.Label(_entry?.DayHighlight); foreach (var choice in AttendanceChoices) choice.IsSelected = choice.Status == _entry?.Attendance; var homework = _entry is null ? null : HomeworkDisplay.Effective(_entry); foreach (var choice in HomeworkChoices) choice.IsSelected = choice.Status == homework; foreach (var choice in DayHighlightChoices) choice.IsSelected = choice.Kind == _entry?.DayHighlight; } } public partial class SeatAssessmentAspectRow : ObservableObject { private readonly Action _apply; [ObservableProperty] private bool _isActive; [ObservableProperty] private int? _value; public int Index { get; } public string Key { get; } public string Label { get; } public AspectValueType ValueType { get; } public int MaxPoints { get; } public string Shortcut => Index switch { 0 => "Q", 1 => "W", 2 => "E", 3 => "R", 4 => "T", _ => "" }; public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value); public ObservableCollection Choices { get; } = []; /// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback), siehe /// ParticipationCountSuggestion — null, wenn keine Strichliste zu diesem Aspekt gehört. private readonly int? _raisedHandCount; public int? SuggestedValue { get; } public bool HasSuggestion => SuggestedValue.HasValue && SuggestedValue != Value; public string SuggestionLabel => $"{_raisedHandCount}× gemeldet → Vorschlag: {ParticipationRatingScale.DisplayLabel(ValueType, SuggestedValue)}"; public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value, Action apply, int? raisedHandCount = null) { Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType; MaxPoints = aspect.MaxPoints; _value = value; _apply = apply; _raisedHandCount = raisedHandCount; SuggestedValue = raisedHandCount is int rhc ? ParticipationCountSuggestion.SuggestQuantity(ValueType, rhc) : null; var steps = ValueType == AspectValueType.Points ? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList() : ParticipationRatingScale.Steps(ValueType).ToList(); foreach (var (step, i) in steps.Select((s, i) => (s, i))) Choices.Add(new SeatRatingChoice(step.Item2, ValueType == AspectValueType.Points ? step.Item1.ToString() : (i + 1).ToString(), step.Item1, step.Item1 == value, ApplyValue)); } public void ApplyValue(int? value) { Value = value; OnPropertyChanged(nameof(DisplayValue)); OnPropertyChanged(nameof(HasSuggestion)); foreach (var choice in Choices) choice.IsSelected = choice.Value == value; _apply(Key, value); } [RelayCommand] private void ApplySuggestion() => ApplyValue(SuggestedValue); public void Adjust(int delta) { if (ValueType == AspectValueType.Points) { ApplyValue(Math.Clamp((Value ?? (delta > 0 ? -1 : MaxPoints + 1)) + delta, 0, MaxPoints)); return; } var steps = ParticipationRatingScale.Steps(ValueType).Select(s => s.Value).ToList(); if (steps.Count == 0) return; var index = Value.HasValue ? steps.IndexOf(Value.Value) : (delta > 0 ? -1 : steps.Count); ApplyValue(steps[Math.Clamp(index + delta, 0, steps.Count - 1)]); } [RelayCommand] private void Clear() => ApplyValue(null); [RelayCommand] private void Increment() => Adjust(1); [RelayCommand] private void Decrement() => Adjust(-1); } public partial class SeatRatingChoice(string label, string shortcut, int value, bool isSelected, Action apply) : ObservableObject { public string Label { get; } = label; public string Shortcut { get; } = shortcut; public int Value { get; } = value; [ObservableProperty] private bool _isSelected = isSelected; [RelayCommand] private void Apply() => apply(Value); } public partial class SeatAttendanceChoice(string symbol, string label, string shortcut, AttendanceStatus? status, Action apply) : ObservableObject { public string Symbol { get; } = symbol; public string Label { get; } = label; public string Shortcut { get; } = shortcut; public AttendanceStatus? Status { get; } = status; [ObservableProperty] private bool _isSelected; [RelayCommand] private void Apply() => apply(Status); } public partial class SeatHomeworkChoice(string symbol, string label, string shortcut, HomeworkStatus? status, Action apply) : ObservableObject { public string Symbol { get; } = symbol; public string Label { get; } = label; public string Shortcut { get; } = shortcut; public HomeworkStatus? Status { get; } = status; [ObservableProperty] private bool _isSelected; [RelayCommand] private void Apply() => apply(Status); } public partial class SeatDayHighlightChoice(string symbol, string label, string shortcut, DayHighlightKind? kind, Action apply) : ObservableObject { public string Symbol { get; } = symbol; public string Label { get; } = label; public string Shortcut { get; } = shortcut; public DayHighlightKind? Kind { get; } = kind; [ObservableProperty] private bool _isSelected; [RelayCommand] private void Apply() => apply(Kind); } 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 bool _isBoardAtBottom; [ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _layoutError = ""; public SeatingPlan? Result { get; private set; } public ObservableCollection ColumnGaps { get; } = []; 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; RebuildColumnGaps(decimal.ToInt32(Columns)); if (editingPlan is null) return; Name = editingPlan.Name ?? ""; Room = editingPlan.Room ?? ""; Rows = editingPlan.Rows; Columns = editingPlan.Columns; IsBoardAtBottom = editingPlan.IsBoardAtBottom; var savedGapWidths = editingPlan.ColumnGapWidths ?? []; for (var i = 0; i < ColumnGaps.Count && i < savedGapWidths.Count; i++) ColumnGaps[i].Width = (decimal)savedGapWidths[i]; } partial void OnColumnsChanged(decimal value) => RebuildColumnGaps(decimal.ToInt32(value)); private void RebuildColumnGaps(int columns) { var previous = ColumnGaps.ToDictionary(g => g.AfterColumn, g => g.Width); ColumnGaps.Clear(); for (var afterColumn = 1; afterColumn < columns; afterColumn++) ColumnGaps.Add(new ColumnGapEditor(afterColumn, previous.GetValueOrDefault(afterColumn))); } [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); plan.IsBoardAtBottom = IsBoardAtBottom; plan.ColumnGapWidths = ColumnGaps.Select(g => decimal.ToDouble(g.Width)).ToList(); try { _plans.Save(plan); Result = plan; } catch (InvalidOperationException ex) { NameError = ex.Message; } } } public partial class ColumnGapEditor(int afterColumn, decimal width) : ObservableObject { public int AfterColumn { get; } = afterColumn; public string Label => $"Nach Platz {AfterColumn}"; [ObservableProperty] private decimal _width = width; }