using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using System.Collections.ObjectModel; using System.Collections.Generic; namespace LehrerApp.Desktop.ViewModels.Groups; // ── Tab-ViewModel ───────────────────────────────────────────────────────────── public partial class ParticipationTabViewModel : ObservableObject { private readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _entries; private readonly IParticipationAspectRepository _aspects; private readonly IStudentRepository _students; private readonly IGroupMembershipRepository _memberships; private readonly IGroupRepository _groups; private readonly ICompetencyDomainRepository _competencyDomains; private Guid _groupId; private string _schoolYear = ""; private Guid? _subjectId; private int _gradeLevel; private GradingSystem _gradingSystem; public Guid GroupId => _groupId; public string SchoolYear => _schoolYear; public GradingSystem GradingSystem => _gradingSystem; public string GroupLabel { get; private set; } = ""; [ObservableProperty] private ParticipationSessionItem? _selectedSession; [ObservableProperty] private string _noDataText = "Keine Bewertungssitzung ausgewählt."; [ObservableProperty] private bool _competencyTagsVisible; [ObservableProperty] private bool _hasCompetencyCatalog; [ObservableProperty] private bool _studentCompetencyRatingsVisible; [ObservableProperty] private int _rebuildColumnsSignal; [ObservableProperty] private bool _isReadOnly; public string SelectedSessionDisplay => SelectedSession?.Display ?? ""; public List ActiveCompetencyCodes { get; private set; } = []; public ObservableCollection Sessions { get; } = []; public ObservableCollection StudentRows { get; } = []; public ObservableCollection Aspects { get; } = []; public ObservableCollection CompetencyTagGroups { get; } = []; public Func>? OnAddSession { get; set; } public Func>? OnEditSession { get; set; } public Func? OnQuickInput { get; set; } public Func? OnStatusQuickInput { get; set; } public Func? OnComputeGrade { get; set; } public Func? OnOpenWizard { get; set; } public Func? OnManageAspects { get; set; } public Func>? OnConfirmDeleteSession { get; set; } public ParticipationTabViewModel( IParticipationSessionRepository sessions, IParticipationRepository entries, IParticipationAspectRepository aspects, IStudentRepository students, IGroupMembershipRepository memberships, IGroupRepository groups, ICompetencyDomainRepository competencyDomains) { _sessions = sessions; _entries = entries; _aspects = aspects; _students = students; _memberships = memberships; _groups = groups; _competencyDomains = competencyDomains; } public void Initialize(Guid groupId, string schoolYear, bool isReadOnly = false) { _groupId = groupId; _schoolYear = schoolYear; IsReadOnly = isReadOnly; var group = _groups.GetById(groupId); _subjectId = group?.SubjectId; _gradeLevel = group?.GradeLevel ?? 0; _gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6; GroupLabel = group?.Name ?? ""; HasCompetencyCatalog = _subjectId.HasValue && _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel).Count > 0; LoadAspects(); LoadSessions(); } private void LoadAspects() { Aspects.Clear(); var defaults = _aspects.GetDefaults(); var specific = _aspects.GetByGroup(_groupId); var all = defaults.Concat(specific).ToList(); if (!all.Any()) { foreach (var a in DefaultParticipationAspects.All) Aspects.Add(new AspectColumnDef(a)); } else { foreach (var a in all) Aspects.Add(new AspectColumnDef(a)); } } public void LoadSessions() { var selectedId = SelectedSession?.Id; Sessions.Clear(); foreach (var s in _sessions.GetByGroup(_groupId)) Sessions.Add(new ParticipationSessionItem(s)); SelectedSession = Sessions.FirstOrDefault(s => s.Id == selectedId) ?? Sessions.FirstOrDefault(); } partial void OnSelectedSessionChanged(ParticipationSessionItem? value) { OnPropertyChanged(nameof(SelectedSessionDisplay)); EditSessionCommand.NotifyCanExecuteChanged(); DeleteSessionCommand.NotifyCanExecuteChanged(); if (value is null) { StudentRows.Clear(); CompetencyTagGroups.Clear(); ActiveCompetencyCodes = []; QuickInputCommand.NotifyCanExecuteChanged(); StatusQuickInputCommand.NotifyCanExecuteChanged(); RebuildColumnsSignal++; return; } LoadCompetencyTags(value.Id); // sets ActiveCompetencyCodes first LoadGrid(value.Id); // uses ActiveCompetencyCodes, fires RebuildColumnsSignal++ } private void LoadGrid(Guid sessionId) { StudentRows.Clear(); var session = _sessions.GetById(sessionId); var sessionDate = session?.Date ?? DateOnly.FromDateTime(DateTime.Today); var students = _students.GetByGroup(_groupId); var memberships = _memberships.GetByGroup(_groupId); var entries = _entries.GetBySession(sessionId); foreach (var s in students) { var membership = memberships.FirstOrDefault(e => e.StudentId == s.Id); if (membership is not null && !GroupMembershipService.IsActiveOn(membership, sessionDate)) continue; var entry = entries.FirstOrDefault(e => e.StudentId == s.Id) ?? new ParticipationEntry { SessionId = sessionId, StudentId = s.Id }; var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes); row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val); row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val); row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val); row.AttendanceChangedCallback = (sid, val) => SaveAttendance(sessionId, sid, val); StudentRows.Add(row); } QuickInputCommand.NotifyCanExecuteChanged(); StatusQuickInputCommand.NotifyCanExecuteChanged(); RebuildColumnsSignal++; } private void SaveRating(Guid sessionId, Guid studentId, string key, int? value) { if (IsReadOnly) return; var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId, }; 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); } public void RefreshCurrentGrid() { if (SelectedSession is not null) LoadGrid(SelectedSession.Id); } private void SaveHomework(Guid sessionId, Guid studentId, HomeworkStatus? value) { if (IsReadOnly) return; var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; entry.Homework = value; entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(value); _entries.Save(entry); } private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value) { if (IsReadOnly) return; var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; entry.Attendance = value; _entries.Save(entry); } [RelayCommand] private void ToggleCompetencyTags() => CompetencyTagsVisible = !CompetencyTagsVisible; partial void OnStudentCompetencyRatingsVisibleChanged(bool value) => RebuildColumnsSignal++; private void LoadCompetencyTags(Guid sessionId) { CompetencyTagGroups.Clear(); var session = _sessions.GetById(sessionId); ActiveCompetencyCodes = session?.CompetencyCodes?.ToList() ?? []; if (!_subjectId.HasValue) return; var active = ActiveCompetencyCodes.ToHashSet(); foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel)) { var group = new CompetencyTagGroup(domain.Name, domain.Code); foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) { var tag = new CompetencyTag(item.Code, item.Description, active.Contains(item.Code)); tag.OnChanged = (code, sel) => OnTagToggled(code, sel); group.Items.Add(tag); } if (group.Items.Count > 0) CompetencyTagGroups.Add(group); } } private void OnTagToggled(string code, bool selected) { if (SelectedSession is null) return; var session = _sessions.GetById(SelectedSession.Id); if (session is null) return; if (selected) { if (!session.CompetencyCodes.Contains(code)) session.CompetencyCodes.Add(code); } else { session.CompetencyCodes.Remove(code); } _sessions.Save(session); ActiveCompetencyCodes = session.CompetencyCodes.ToList(); if (StudentCompetencyRatingsVisible) LoadGrid(SelectedSession.Id); // reloads rows with updated cells, fires RebuildColumnsSignal++ } [RelayCommand] private async Task AddSession() { if (OnAddSession is null) return; var session = await OnAddSession(); if (session is null) return; session.GroupId = _groupId; _sessions.Save(session); Sessions.Clear(); foreach (var s in _sessions.GetByGroup(_groupId)) Sessions.Add(new ParticipationSessionItem(s)); SelectedSession = Sessions.FirstOrDefault(s => s.Id == session.Id); } [RelayCommand(CanExecute = nameof(CanEditSession))] private async Task EditSession() { if (SelectedSession is null || OnEditSession is null) return; var session = _sessions.GetById(SelectedSession.Id); if (session is null) return; var edited = await OnEditSession(session); if (edited is null) return; edited.GroupId = _groupId; _sessions.Save(edited); LoadSessions(); } private bool CanEditSession() => !IsReadOnly && SelectedSession is not null; partial void OnIsReadOnlyChanged(bool value) { EditSessionCommand.NotifyCanExecuteChanged(); DeleteSessionCommand.NotifyCanExecuteChanged(); } [RelayCommand(CanExecute = nameof(CanQuickInput))] private async Task QuickInput() { if (OnQuickInput is null) return; await OnQuickInput(this); if (SelectedSession is not null) LoadGrid(SelectedSession.Id); } private bool CanQuickInput() => SelectedSession is not null && StudentRows.Count > 0; [RelayCommand(CanExecute = nameof(CanQuickInput))] private async Task StatusQuickInput() { if (OnStatusQuickInput is null) return; await OnStatusQuickInput(this); if (SelectedSession is not null) LoadGrid(SelectedSession.Id); } [RelayCommand] private async Task ComputeGrade() { if (OnComputeGrade is null) return; await OnComputeGrade(this); } [RelayCommand] private async Task OpenWizard() { if (OnOpenWizard is null) return; await OnOpenWizard(this); } [RelayCommand] private async Task ManageAspects() { if (IsReadOnly || OnManageAspects is null) return; await OnManageAspects(this); LoadAspects(); if (SelectedSession is not null) LoadGrid(SelectedSession.Id); } [RelayCommand(CanExecute = nameof(CanDeleteSession))] private async Task DeleteSession() { if (SelectedSession is null) return; var entryCount = _entries.GetBySession(SelectedSession.Id).Count; if (OnConfirmDeleteSession is not null && !await OnConfirmDeleteSession(SelectedSession, entryCount)) return; _sessions.Delete(SelectedSession.Id); LoadSessions(); } private bool CanDeleteSession() => !IsReadOnly && SelectedSession is not null; public void SaveNote(Guid sessionId, Guid studentId, string note) { var entry = _entries.GetBySessionAndStudent(sessionId, studentId); if (entry is null) return; entry.Note = note; _entries.Save(entry); } private void SaveCompetencyRating(Guid sessionId, Guid studentId, string code, int? value) { var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId, }; var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code); if (value is null) { if (existing is not null) entry.CompetencyRatings.Remove(existing); } else { if (existing is null) entry.CompetencyRatings.Add(new CompetencyRating { Code = code, Value = value.Value }); else existing.Value = value.Value; } _entries.Save(entry); } } // ── Zeilendaten für das Bewertungsraster ───────────────────────────────────── public partial class ParticipationStudentRow : ObservableObject { public Guid StudentId { get; } public string Name { get; } private readonly IReadOnlyList _aspectDefs; public ObservableCollection Cells { get; } = []; public ObservableCollection CompetencyCells { get; } = []; [ObservableProperty] private HomeworkStatus? _homework; [ObservableProperty] private AttendanceStatus? _attendance; public string AttendanceLabel => AttendanceDisplay.ShortLabel(Attendance); public string AttendanceTooltip => AttendanceDisplay.Label(Attendance); /// Abwesend im Sinne der Mitarbeitsbewertung: eine Bewertung ergibt für diese Stunde keinen /// Sinn, unabhängig davon, ob die Abwesenheit entschuldigt ist oder noch geklärt werden muss. public bool IsAbsent => Attendance is not null and not AttendanceStatus.Present and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate; public string HomeworkSymbol => HomeworkDisplay.Symbol(Homework); public string HomeworkTooltip => HomeworkDisplay.Label(Homework); public Action? OnRatingChanged { get; set; } public Action? OnCompetencyRatingChanged { get; set; } public Action? HomeworkChangedCallback { get; set; } public Action? AttendanceChangedCallback { get; set; } public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry, List aspects, List competencyCodes) { StudentId = id; Name = name; _aspectDefs = aspects; _homework = HomeworkDisplay.Effective(entry); _attendance = entry.Attendance; foreach (var a in aspects) { var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key); var cell = new RatingCell(id, a.Key, existing?.Value, a.ValueType, a.MaxPoints); cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val); Cells.Add(cell); } foreach (var code in competencyCodes) { var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code); var cell = new RatingCell(id, code, existing?.Value); cell.OnChanged = (sid, key, val) => OnCompetencyRatingChanged?.Invoke(sid, key, val); CompetencyCells.Add(cell); } } // Liest aus Cells (per SetValue laufend aktuell gehalten), NICHT aus dem ursprünglichen // ParticipationEntry-Snapshot: SetRating() schreibt nur in Cells + feuert OnRatingChanged // (der Callback speichert über eine EIGENE, neu aus dem Repository geladene Entry-Instanz — // der hier ursprünglich referenzierte Entry-Snapshot bekommt diese Änderung nie zu sehen). public int? GetRating(string key) => Cells.FirstOrDefault(c => c.AspectKey == key)?.Value; public void SetRating(string key, int? value) { var cell = Cells.FirstOrDefault(c => c.AspectKey == key); cell?.SetValue(value); OnRatingChanged?.Invoke(StudentId, key, value); } [RelayCommand] private void ToggleHomework() { Homework = HomeworkDisplay.Next(Homework); OnPropertyChanged(nameof(HomeworkSymbol)); OnPropertyChanged(nameof(HomeworkTooltip)); HomeworkChangedCallback?.Invoke(StudentId, Homework); } public void SetHomework(HomeworkStatus? value) { Homework = value; OnPropertyChanged(nameof(HomeworkSymbol)); OnPropertyChanged(nameof(HomeworkTooltip)); HomeworkChangedCallback?.Invoke(StudentId, value); } [RelayCommand] private void CycleAttendance() { Attendance = Attendance switch { null => AttendanceStatus.Present, AttendanceStatus.Present => AttendanceStatus.ExcusePending, AttendanceStatus.ExcusePending => AttendanceStatus.Excused, AttendanceStatus.Excused => AttendanceStatus.Unexcused, AttendanceStatus.Unexcused => AttendanceStatus.Truant, AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent, AttendanceStatus.OtherSchoolEvent => null, _ => null, }; OnPropertyChanged(nameof(AttendanceLabel)); OnPropertyChanged(nameof(AttendanceTooltip)); OnPropertyChanged(nameof(IsAbsent)); AttendanceChangedCallback?.Invoke(StudentId, Attendance); } // Direktes Setzen (z.B. aus dem Grading-Wizard heraus), ohne den Zyklus zu durchlaufen. public void SetAttendance(AttendanceStatus? value) { Attendance = value; OnPropertyChanged(nameof(AttendanceLabel)); OnPropertyChanged(nameof(AttendanceTooltip)); OnPropertyChanged(nameof(IsAbsent)); AttendanceChangedCallback?.Invoke(StudentId, value); } } // ── Hausaufgaben-Anzeige ───────────────────────────────────────────────────── public static class HomeworkDisplay { public static HomeworkStatus? Effective(ParticipationEntry entry) => entry.Homework ?? (entry.HomeworkMissing ? HomeworkStatus.MissingOpen : null); public static HomeworkStatus? Next(HomeworkStatus? status) => status switch { null => HomeworkStatus.Completed, HomeworkStatus.Completed => HomeworkStatus.PartiallyCompleted, HomeworkStatus.PartiallyCompleted => HomeworkStatus.PartialSubmittedLate, HomeworkStatus.PartialSubmittedLate => HomeworkStatus.PartialMissingOverdue, HomeworkStatus.PartialMissingOverdue => HomeworkStatus.MissingOpen, HomeworkStatus.MissingOpen => HomeworkStatus.MissingOverdue, HomeworkStatus.MissingOverdue => HomeworkStatus.SubmittedLate, HomeworkStatus.SubmittedLate => null, _ => null, }; public static string Label(HomeworkStatus? status) => status switch { null => "Keine Hausaufgabe aufgegeben", HomeworkStatus.Completed => "Hausaufgabe gemacht", HomeworkStatus.PartiallyCompleted => "Teilweise angefertigt – Rest offen", HomeworkStatus.PartialSubmittedLate => "Teilweise angefertigt – Rest nachgereicht", HomeworkStatus.PartialMissingOverdue => "Teilweise angefertigt – Rest nicht nachgereicht", HomeworkStatus.MissingOpen => "Nicht gemacht – Nachreichen offen", HomeworkStatus.MissingOverdue => "Nicht gemacht – nicht mehr nachgereicht", HomeworkStatus.SubmittedLate => "Hausaufgabe nachgereicht", _ => "Keine Hausaufgabe aufgegeben", }; public static string Symbol(HomeworkStatus? status) => status switch { null => "·", HomeworkStatus.Completed => "✓", HomeworkStatus.PartiallyCompleted => "◐", HomeworkStatus.PartialSubmittedLate => "◕", HomeworkStatus.PartialMissingOverdue => "◒", HomeworkStatus.MissingOpen => "!", HomeworkStatus.MissingOverdue => "✕", HomeworkStatus.SubmittedLate => "↺", _ => "·", }; public static string Color(HomeworkStatus? status) => status switch { HomeworkStatus.Completed => "#2E9D57", HomeworkStatus.PartiallyCompleted => "#D98200", HomeworkStatus.PartialSubmittedLate => "#7F77DD", HomeworkStatus.PartialMissingOverdue => "#D64545", HomeworkStatus.MissingOpen => "#D98200", HomeworkStatus.MissingOverdue => "#D64545", HomeworkStatus.SubmittedLate => "#7F77DD", _ => "", }; public static bool CountsAsMissing(HomeworkStatus? status) => status is HomeworkStatus.MissingOpen or HomeworkStatus.MissingOverdue or HomeworkStatus.PartiallyCompleted or HomeworkStatus.PartialMissingOverdue; } // ── Anwesenheits-Anzeige ────────────────────────────────────────────────────── public static class AttendanceDisplay { public static string Label(AttendanceStatus? s) => s switch { null => "Noch nicht kontrolliert", AttendanceStatus.Present => "Anwesend", AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)", AttendanceStatus.Excused => "Krank, entschuldigt", AttendanceStatus.Unexcused => "Krank, unentschuldigt", AttendanceStatus.Truant => "Geschwänzt", AttendanceStatus.OtherSchoolEvent => "Andere Schulveranstaltung", AttendanceStatus.Late => "Verspätet", AttendanceStatus.SignificantlyLate => "Erheblich verspätet", AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig", AttendanceStatus.LearningIsland => "Lerninsel", AttendanceStatus.Suspended => "Suspendiert", _ => "Anwesend", }; public static string ShortLabel(AttendanceStatus? s) => s switch { null => "", AttendanceStatus.Present => "✓", AttendanceStatus.ExcusePending => "?", AttendanceStatus.Excused => "⊘", AttendanceStatus.Unexcused => "!", AttendanceStatus.Truant => "✕", AttendanceStatus.OtherSchoolEvent => "◇", AttendanceStatus.Late => "V", AttendanceStatus.SignificantlyLate => "V!", AttendanceStatus.LeftDuringClass => "A", AttendanceStatus.LearningIsland => "L", AttendanceStatus.Suspended => "S", _ => "", }; public static string Color(AttendanceStatus? s) => s switch { AttendanceStatus.Present => "#2E9D57", AttendanceStatus.ExcusePending => "#D98200", AttendanceStatus.Excused => "#4C86A8", AttendanceStatus.Unexcused => "#D96C00", AttendanceStatus.Truant => "#D64545", AttendanceStatus.OtherSchoolEvent => "#5277C3", AttendanceStatus.Late => "#D98200", AttendanceStatus.SignificantlyLate => "#D96C00", AttendanceStatus.LeftDuringClass => "#D64545", AttendanceStatus.LearningIsland => "#5277C3", AttendanceStatus.Suspended => "#6B6576", _ => "", }; } // ── Eine Bewertungszelle ────────────────────────────────────────────────────── public partial class RatingCell : ObservableObject { public Guid StudentId { get; } public string AspectKey { get; } public AspectValueType Type { get; } public int MaxPoints { get; } [ObservableProperty] private int? _value; [ObservableProperty] private string _displayLabel = ""; public Action? OnChanged { get; set; } public RatingCell(Guid studentId, string key, int? value, AspectValueType type = AspectValueType.Scale5, int maxPoints = 5) { StudentId = studentId; AspectKey = key; Type = type; MaxPoints = maxPoints; _value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value; UpdateLabel(); } public void SetValue(int? value) { Value = Type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, MaxPoints) : value; UpdateLabel(); OnChanged?.Invoke(StudentId, AspectKey, Value); } [RelayCommand] private void CycleUp() { if (Type == AspectValueType.Points) { SetValue(Value is null ? 0 : Math.Min(MaxPoints, Value.Value + 1)); return; } var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList(); if (steps.Count == 0) return; var idx = Value is null ? -1 : steps.IndexOf(Value.Value); SetValue(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]); } [RelayCommand] private void CycleDown() { if (Type == AspectValueType.Points) { SetValue(Value is null ? MaxPoints : Math.Max(0, Value.Value - 1)); return; } var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList(); if (steps.Count == 0) return; var idx = Value is null ? steps.Count : steps.IndexOf(Value.Value); SetValue(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]); } [RelayCommand] private void Clear() => SetValue(null); private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value); } // ── Kompetenz-Tags ──────────────────────────────────────────────────────────── public class CompetencyTagGroup(string name, string code) { public string Name { get; } = name; public string Code { get; } = code; public string DisplayName { get; } = string.IsNullOrEmpty(code) ? name : $"{name} ({code})"; public List Items { get; } = []; } public partial class CompetencyTag : ObservableObject { public string Code { get; } public string Description { get; } public string Display { get; } [ObservableProperty] private bool _isSelected; public Action? OnChanged { get; set; } public CompetencyTag(string code, string description, bool isSelected) { Code = code; Description = description; Display = string.IsNullOrEmpty(code) ? description : $"[{code}] {description}"; _isSelected = isSelected; } partial void OnIsSelectedChanged(bool value) => OnChanged?.Invoke(Code, value); } // ── Hilfsklassen ────────────────────────────────────────────────────────────── public class AspectColumnDef { public string Key { get; } public string Label { get; } public AspectValueType ValueType { get; } public int MaxPoints { get; } public AspectColumnDef(ParticipationAspect a) { Key = a.Key; Label = a.Label; ValueType = a.ValueType; MaxPoints = a.MaxPoints; } } public class ParticipationSessionItem { public Guid Id { get; } public string Display { get; } public string Comment { get; } public DateOnly Date { get; } public ParticipationSessionItem(ParticipationSession s) { Id = s.Id; Date = s.Date; Comment = s.Comment ?? ""; Display = s.Comment is { Length: > 0 } ? $"{s.Date:dd.MM.yyyy} – {s.Comment}" : s.Date.ToString("dd.MM.yyyy"); } } // ── Dialog: Sitzung anlegen ─────────────────────────────────────────────────── public partial class AddSessionDialogViewModel : ObservableObject { private readonly ParticipationSession? _editingSession; [ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today); [ObservableProperty] private string _comment = ""; [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"); [ObservableProperty] private string _dateTextError = ""; public ParticipationSession? Result { get; private set; } public bool IsEditing => _editingSession is not null; public string DialogTitle => IsEditing ? "Bewertungszeitpunkt bearbeiten" : "Bewertungszeitpunkt anlegen"; public string SaveButtonText => IsEditing ? "Speichern" : "Anlegen"; public AddSessionDialogViewModel(ParticipationSession? editingSession = null) { _editingSession = editingSession; if (editingSession is null) return; Date = editingSession.Date; DateText = editingSession.Date.ToString("dd.MM.yyyy"); Comment = editingSession.Comment ?? ""; } [RelayCommand] private void Save() { if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, System.Globalization.DateTimeStyles.None, out var date)) { DateTextError = "Format TT.MM.JJJJ."; return; } DateTextError = ""; var session = _editingSession ?? new ParticipationSession(); session.Date = date; session.Comment = Comment.Trim(); Result = session; } } // ── Dialog: Schnelleingabe ──────────────────────────────────────────────────── public partial class QuickInputViewModel : ObservableObject { private readonly List _rows; private readonly List _aspects; [ObservableProperty] private int _studentIndex; [ObservableProperty] private int _aspectIndex; [ObservableProperty] private string _studentName = ""; [ObservableProperty] private string _currentAspectLabel = ""; [ObservableProperty] private string _currentValueLabel = ""; [ObservableProperty] private string _progressText = ""; [ObservableProperty] private bool _currentStudentIsAbsent; [ObservableProperty] private string _currentStudentAttendanceLabel = ""; /// Dimmt Name/Aspektliste, wenn der aktuelle Schüler abwesend ist — kein Blockieren der /// Eingabe (manche Bewertungssysteme wollen trotzdem einen Eintrag, z.B. "0 Punkte"), nur ein /// visueller Hinweis, dass eine Bewertung hier normalerweise keinen Sinn ergibt. public double CurrentStudentContentOpacity => CurrentStudentIsAbsent ? 0.4 : 1.0; public ObservableCollection AspectRows { get; } = []; /// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere /// gültige Zifferntasten als Scale5, Points nutzt 0-9 als direkte Punkteingabe statt fester /// Stufen. Ändert sich mit AspectIndex, siehe OnAspectIndexChanged. public string HotkeyLegend { get { var ratingHint = CurrentAspectType() switch { AspectValueType.Scale5 => "1–5 bewerten", AspectValueType.Scale3 => "1–3 bewerten", AspectValueType.Binary => "1 Nein / 2 Ja", AspectValueType.Points => $"0–9 Punkte eingeben (bis {CurrentAspectMaxPoints()})", _ => "1–5 bewerten", }; return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " + "Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · Esc schließen"; } } public QuickInputViewModel(List rows, List aspects) { _rows = rows; _aspects = aspects; if (rows.Any()) ShowStudent(0); } partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend)); partial void OnCurrentStudentIsAbsentChanged(bool value) => OnPropertyChanged(nameof(CurrentStudentContentOpacity)); private AspectValueType CurrentAspectType() => _aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType; private int CurrentAspectMaxPoints() => _aspects.Count == 0 ? 5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].MaxPoints; private void ShowStudent(int index) { if (index < 0 || index >= _rows.Count) return; StudentIndex = index; var row = _rows[index]; StudentName = row.Name; ProgressText = $"{index + 1} / {_rows.Count}"; CurrentStudentIsAbsent = row.IsAbsent; CurrentStudentAttendanceLabel = row.AttendanceTooltip; AspectRows.Clear(); foreach (var (a, i) in _aspects.Select((a, i) => (a, i))) { var val = row.GetRating(a.Key); AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex, a.ValueType)); } UpdateCurrentAspect(); } private void UpdateCurrentAspect() { if (!_aspects.Any()) return; var safeIdx = Math.Clamp(AspectIndex, 0, _aspects.Count - 1); CurrentAspectLabel = _aspects[safeIdx].Label; var row = AspectRows.ElementAtOrDefault(safeIdx); CurrentValueLabel = row?.DisplayLabel ?? ""; foreach (var r in AspectRows) r.IsActive = r.Index == safeIdx; } public void SetRatingByNumber(int num) { var type = CurrentAspectType(); if (type == AspectValueType.Points) { ApplyRating(Math.Clamp(num, 0, CurrentAspectMaxPoints())); return; } var steps = ParticipationRatingScale.Steps(type); if (num < 1 || num > steps.Count) return; ApplyRating(steps[num - 1].Value); } public void IncrementRating() { var cell = GetCurrentCell(); if (cell is null) return; var type = CurrentAspectType(); if (type == AspectValueType.Points) { var max = CurrentAspectMaxPoints(); ApplyRating(cell.Value is null ? 0 : Math.Min(max, cell.Value.Value + 1)); return; } var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList(); if (steps.Count == 0) return; var idx = cell.Value is null ? -1 : steps.IndexOf(cell.Value.Value); ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]); } /// Nutzer-Feedback: eine aus Versehen gesetzte Bewertung (z.B. Qualität, obwohl der Schüler /// sich nie gemeldet hat) muss sich wieder auf "nicht bewertet" zurücksetzen lassen, ohne /// einen inhaltlich falschen Wert stehen lassen zu müssen. Bewusst eine eigene Aktion statt /// über 0/1 zu laufen — 0 ("--") ist bei Quantität ein echter, beobachteter Wert ("hat sich /// nicht gemeldet"), kein Platzhalter für "nicht ermittelt". public void ClearRating() => ApplyRating(null); public void DecrementRating() { var cell = GetCurrentCell(); if (cell is null) return; var type = CurrentAspectType(); if (type == AspectValueType.Points) { ApplyRating(cell.Value is null ? CurrentAspectMaxPoints() : Math.Max(0, cell.Value.Value - 1)); return; } var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList(); if (steps.Count == 0) return; var idx = cell.Value is null ? steps.Count : steps.IndexOf(cell.Value.Value); ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]); } private void ApplyRating(int? val) { if (_rows.Count == 0 || !_aspects.Any()) return; var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key; _rows[StudentIndex].SetRating(key, val); var row = AspectRows.ElementAtOrDefault(AspectIndex); if (row is not null) { row.Value = val; row.UpdateLabel(); } CurrentValueLabel = ParticipationRatingScale.DisplayLabel(CurrentAspectType(), val); } public void SelectAspect(int index) { if (index < 0 || index >= _aspects.Count) return; AspectIndex = index; UpdateCurrentAspect(); } public void NextAspect() { AspectIndex = (AspectIndex + 1) % _aspects.Count; UpdateCurrentAspect(); } public void PreviousAspect() { AspectIndex = (AspectIndex - 1 + _aspects.Count) % _aspects.Count; UpdateCurrentAspect(); } public void NextStudent() { if (StudentIndex >= _rows.Count - 1) return; AspectIndex = 0; ShowStudent(StudentIndex + 1); } public void PreviousStudent() { if (StudentIndex <= 0) return; AspectIndex = 0; ShowStudent(StudentIndex - 1); } public bool IsLastStudent => StudentIndex >= _rows.Count - 1; private RatingCell? GetCurrentCell() { if (_rows.Count == 0 || !_aspects.Any()) return null; var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key; return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key); } } public partial class QuickAspectRow : ObservableObject { public int Index { get; } public string Label { get; } public AspectValueType Type { get; } [ObservableProperty] private bool _isActive; [ObservableProperty] private string _displayLabel = ""; public int? Value { get; set; } public QuickAspectRow(int index, string label, int? value, bool isActive, AspectValueType type = AspectValueType.Scale5) { Index = index; Label = label; Value = value; IsActive = isActive; Type = type; UpdateLabel(); } public void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value); } // ── Schnelleingabe Anwesenheit / Hausaufgaben ──────────────────────────────── public partial class AttendanceHomeworkQuickInputViewModel : ObservableObject { public List Rows { get; } public string SessionLabel { get; } [ObservableProperty] private ParticipationStudentRow? _selectedRow; [ObservableProperty] private bool _advanceAfterInput = true; public AttendanceHomeworkQuickInputViewModel( IEnumerable rows, string sessionLabel) { Rows = rows.ToList(); SessionLabel = sessionLabel; SelectedRow = Rows.FirstOrDefault(); } public void ApplyAttendance(ParticipationStudentRow row, AttendanceStatus? status) { SelectedRow = row; row.SetAttendance(status); AdvanceIfRequested(); } public void ApplyHomework(ParticipationStudentRow row, HomeworkStatus? status) { SelectedRow = row; row.SetHomework(status); AdvanceIfRequested(); } public void MoveSelection(int delta) { if (Rows.Count == 0) return; var current = SelectedRow is null ? 0 : Rows.IndexOf(SelectedRow); SelectedRow = Rows[Math.Clamp(current + delta, 0, Rows.Count - 1)]; } private void AdvanceIfRequested() { if (AdvanceAfterInput) MoveSelection(1); } } // ── Aspekte verwalten (3.1) ────────────────────────────────────────────────── /// Deutsche Anzeige für , gleiches Muster wie NiveauDisplay/ /// GradeCategoryDisplay — ComboBox bindet an das Enum sonst über ToString() (englische Namen). public static class AspectValueTypeDisplay { public static string[] Options { get; } = ["Skala 1–5", "Skala 1–3", "Ja/Nein", "Punkte"]; public static string ToName(AspectValueType t) => t switch { AspectValueType.Scale5 => "Skala 1–5", AspectValueType.Scale3 => "Skala 1–3", AspectValueType.Binary => "Ja/Nein", AspectValueType.Points => "Punkte", _ => "Skala 1–5", }; public static AspectValueType FromName(string? name) => name switch { "Skala 1–3" => AspectValueType.Scale3, "Ja/Nein" => AspectValueType.Binary, "Punkte" => AspectValueType.Points, _ => AspectValueType.Scale5, }; } /// Eine Zeile in der Aspekt-Verwaltung (3.1.1/3.1.2/3.1.4). Label/Gewichtung/Aktiv-Status /// speichern bei jeder Änderung sofort (gleiches Muster wie AspectWeightItem in 3.2.1) — /// "Schlüssel" ist bewusst nur beim Neuanlegen editierbar, nicht nachträglich in der Zeile: er /// ist die Verknüpfung zu historischen AspectRating-Einträgen (per Key, nicht per Id), ein /// nachträgliches Umbenennen würde alte Bewertungen dieses Aspekts unauffindbar machen. public partial class AspectEditItem : ObservableObject { private readonly ParticipationAspect _aspect; private readonly IParticipationAspectRepository _repo; public Guid Id => _aspect.Id; public string Key => _aspect.Key; [ObservableProperty] private string _label; [ObservableProperty] private string _valueTypeName; [ObservableProperty] private int _maxPoints; [ObservableProperty] private double _weight; [ObservableProperty] private bool _isActive; [ObservableProperty] private string _errorMessage = ""; public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; // Nur bei ValueType "Punkte" relevant (3.1.3) — MaxPoints-Feld nur dann in der Verwaltung // anzeigen, siehe ParticipationAspectsDialog.axaml. public bool IsPointsType => AspectValueTypeDisplay.FromName(ValueTypeName) == AspectValueType.Points; public Action? OnDelete { get; set; } public IRelayCommand DeleteCommand { get; } public IRelayCommand MoveUpCommand { get; } public IRelayCommand MoveDownCommand { get; } public AspectEditItem(ParticipationAspect aspect, IParticipationAspectRepository repo, Action? onMoveUp = null, Action? onMoveDown = null) { _aspect = aspect; _repo = repo; _label = aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(aspect.ValueType); _maxPoints = aspect.MaxPoints; _weight = aspect.Weight; _isActive = aspect.IsActive; DeleteCommand = new RelayCommand(() => OnDelete?.Invoke(this)); MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp); MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown); } private bool _canMoveUp; private bool _canMoveDown; internal void SetMoveState(bool canMoveUp, bool canMoveDown) { _canMoveUp = canMoveUp; _canMoveDown = canMoveDown; MoveUpCommand.NotifyCanExecuteChanged(); MoveDownCommand.NotifyCanExecuteChanged(); } internal void SetSortOrder(int sortOrder) { if (_aspect.SortOrder == sortOrder) return; _aspect.SortOrder = sortOrder; _repo.Save(_aspect); } private void TrySave(Action apply) { ErrorMessage = ""; var before = (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive); apply(); try { _repo.Save(_aspect); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { // Zurückrollen, damit Feld und gespeicherter Stand nicht auseinanderlaufen. Bewusst // die Backing-Felder direkt gesetzt statt der generierten Properties — über die // Properties würde erneut On{X}Changed feuern und damit wieder TrySave aufrufen. #pragma warning disable MVVMTK0034 (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive) = before; _label = _aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(_aspect.ValueType); _maxPoints = _aspect.MaxPoints; _weight = _aspect.Weight; _isActive = _aspect.IsActive; #pragma warning restore MVVMTK0034 OnPropertyChanged(nameof(Label)); OnPropertyChanged(nameof(ValueTypeName)); OnPropertyChanged(nameof(MaxPoints)); OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive)); ErrorMessage = ex.Message; } } partial void OnLabelChanged(string value) => TrySave(() => _aspect.Label = value); partial void OnValueTypeNameChanged(string value) { TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value)); OnPropertyChanged(nameof(IsPointsType)); } partial void OnMaxPointsChanged(int value) => TrySave(() => _aspect.MaxPoints = value); partial void OnWeightChanged(double value) => TrySave(() => _aspect.Weight = value); partial void OnIsActiveChanged(bool value) => TrySave(() => _aspect.IsActive = value); } /// Verwaltungsdialog für ParticipationAspect (3.1.1/3.1.2/3.1.4), pro Gruppe. Verwaltet bewusst /// nur die gruppenspezifischen Aspekte (GroupId = diese Gruppe), nicht den globalen /// Standardkatalog (GroupId = null) — der wird bislang nirgends befüllt (siehe /// DefaultParticipationAspects, reiner In-Memory-Fallback ohne UI) und eine Änderung dort würde /// sofort alle Gruppen betreffen; das wäre ein eigener, separat zu entscheidender Schritt. public partial class ParticipationAspectsDialogViewModel : ObservableObject { private readonly IParticipationAspectRepository _repo; private readonly Guid _groupId; [ObservableProperty] private string _newKey = ""; [ObservableProperty] private string _newLabel = ""; [ObservableProperty] private string _newValueTypeName = AspectValueTypeDisplay.Options[0]; [ObservableProperty] private int _newMaxPoints = 5; [ObservableProperty] private string _newAspectError = ""; public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; public bool IsNewPointsType => AspectValueTypeDisplay.FromName(NewValueTypeName) == AspectValueType.Points; public ObservableCollection Aspects { get; } = []; public Func>? OnConfirmDelete { get; set; } public ParticipationAspectsDialogViewModel(IParticipationAspectRepository repo, Guid groupId) { _repo = repo; _groupId = groupId; Load(); } partial void OnNewValueTypeNameChanged(string value) => OnPropertyChanged(nameof(IsNewPointsType)); private void Load() { Aspects.Clear(); foreach (var a in _repo.GetAllByGroup(_groupId)) Aspects.Add(CreateItem(a)); RefreshMoveState(); } private AspectEditItem CreateItem(ParticipationAspect a) => new(a, _repo, item => MoveAspect(item, -1), item => MoveAspect(item, 1)) { OnDelete = DeleteAspectAsync }; private void MoveAspect(AspectEditItem item, int offset) { var oldIndex = Aspects.IndexOf(item); var newIndex = oldIndex + offset; if (oldIndex < 0 || newIndex < 0 || newIndex >= Aspects.Count) return; Aspects.Move(oldIndex, newIndex); for (var i = 0; i < Aspects.Count; i++) Aspects[i].SetSortOrder(i); RefreshMoveState(); } private void RefreshMoveState() { for (var i = 0; i < Aspects.Count; i++) Aspects[i].SetMoveState(i > 0, i < Aspects.Count - 1); } [RelayCommand] private void AddAspect() { NewAspectError = ""; if (string.IsNullOrWhiteSpace(NewKey)) { NewAspectError = "Schlüssel erforderlich."; return; } if (string.IsNullOrWhiteSpace(NewLabel)) { NewAspectError = "Bezeichnung erforderlich."; return; } var aspect = new ParticipationAspect { GroupId = _groupId, Key = NewKey.Trim(), Label = NewLabel.Trim(), ValueType = AspectValueTypeDisplay.FromName(NewValueTypeName), MaxPoints = NewMaxPoints, SortOrder = Aspects.Count, }; try { _repo.Save(aspect); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) { NewAspectError = ex.Message; return; } Aspects.Add(CreateItem(aspect)); RefreshMoveState(); NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; NewMaxPoints = 5; } private async void DeleteAspectAsync(AspectEditItem item) { if (OnConfirmDelete is not null && !await OnConfirmDelete(item)) return; _repo.Delete(item.Id); Aspects.Remove(item); for (var i = 0; i < Aspects.Count; i++) Aspects[i].SetSortOrder(i); RefreshMoveState(); } }