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; 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. private ParticipationSessionOption? EnsureTodaySession() { if (SelectedSession is not null) return SelectedSession; if (!IsEditable) return null; var created = new ParticipationSession { GroupId = _groupId, Date = DateOnly.FromDateTime(DateTime.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. /// public void SelectOrCreateSessionForLesson(Lesson lesson) { var existing = TodaySessions.FirstOrDefault(s => s.LessonId == lesson.Id); 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)); } UpdateAssignmentSummary(); RefreshSeatLessonData(); } OnPropertyChanged(nameof(HasSelectedPlan)); NotifyCommands(); } 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); } } 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(); } partial void OnIsEditModeChanged(bool value) { 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(); } } 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 { 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 = ""; public ObservableCollection SituationTags { get; } = []; public bool HasAttendanceBadge => AttendanceBadge.Length > 0; public bool HasHomeworkBadge => HomeworkBadge.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 : LessonOpacity; 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) { Row = row; Column = column; Options = options; _selectedOption = selectedOption; _onChanged = onChanged; _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))); } partial void OnSelectedOptionChanged(StudentSeatOption value) { OnPropertyChanged(nameof(IsOccupied)); OnPropertyChanged(nameof(StudentName)); OnPropertyChanged(nameof(ShowLessonOverview)); OnPropertyChanged(nameof(CanToggleHidden)); if (!_suppressChange) _onChanged(this); } 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) { _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)); 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)); } } 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"; 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 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; AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating)); } BuildAttendanceChoices(); BuildHomeworkChoices(); 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)); } 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); } 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 RefreshStatusChoices() { AttendanceLabel = AttendanceDisplay.Label(_entry?.Attendance); HomeworkLabel = HomeworkDisplay.Label(_entry is null ? null : HomeworkDisplay.Effective(_entry)); 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; } } 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; } = []; public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value, Action apply) { Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType; MaxPoints = aspect.MaxPoints; _value = value; _apply = apply; 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)); foreach (var choice in Choices) choice.IsSelected = choice.Value == value; _apply(Key, value); } 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 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; }