diff --git a/LehrerApp.Core/Models/Participation.cs b/LehrerApp.Core/Models/Participation.cs index 3018540..67bb6e8 100644 --- a/LehrerApp.Core/Models/Participation.cs +++ b/LehrerApp.Core/Models/Participation.cs @@ -44,6 +44,7 @@ public class ParticipationAspect public string Key { get; set; } = ""; public string Label { get; set; } = ""; public AspectValueType ValueType { get; set; } = AspectValueType.Scale5; + public double Weight { get; set; } = 1.0; public bool IsActive { get; set; } = true; public int SortOrder { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; diff --git a/LehrerApp.Core/Services/GradingService.cs b/LehrerApp.Core/Services/GradingService.cs index f0e29a9..ab9ad73 100644 --- a/LehrerApp.Core/Services/GradingService.cs +++ b/LehrerApp.Core/Services/GradingService.cs @@ -55,6 +55,15 @@ public class GradingService return null; } + /// Wandelt eine Mitarbeit-Bewertung im Bereich -2..+2 in eine Note/Punktzahl + /// nach dem jeweiligen Notensystem der Gruppe um (0 % = -2, 100 % = +2). + public string ParticipationGrade(double averageRating, GradingSystem system) + { + var percent = Math.Clamp((averageRating + 2.0) / 4.0 * 100.0, 0, 100); + var key = system == GradingSystem.Points0To15 ? DefaultKey0To15() : DefaultKey1To6(); + return CalculateGrade(percent, 100, key); + } + public double WeightedAverage(List<(string Grade, double Weight)> grades) { var numeric = grades diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs new file mode 100644 index 0000000..94110eb --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs @@ -0,0 +1,252 @@ +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.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Aggregation zur Mitarbeitsnote (3.2) ───────────────────────────────────── + +public partial class ParticipationGradeDialogViewModel : ObservableObject +{ + private readonly IParticipationSessionRepository _sessions; + private readonly IParticipationRepository _entries; + private readonly IParticipationAspectRepository _aspects; + private readonly IStudentRepository _students; + private readonly IEnrollmentRepository _enrollments; + private readonly IGradeRepository _grades; + private readonly GradingService _grading; + private readonly Guid _groupId; + private readonly string _schoolYear; + private readonly GradingSystem _gradingSystem; + + [ObservableProperty] private ParticipationPeriodOption _selectedPeriod; + [ObservableProperty] private string _statusMessage = ""; + + public List PeriodOptions { get; } = + [ + new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"), + new(ParticipationPeriod.H1, "1. Halbjahr"), + new(ParticipationPeriod.H2, "2. Halbjahr"), + ]; + + public ObservableCollection Rows { get; } = []; + public ObservableCollection AspectWeights { get; } = []; + + public ParticipationGradeDialogViewModel( + IParticipationSessionRepository sessions, IParticipationRepository entries, + IParticipationAspectRepository aspects, IStudentRepository students, + IEnrollmentRepository enrollments, IGradeRepository grades, GradingService grading, + Guid groupId, string schoolYear, GradingSystem gradingSystem) + { + _sessions = sessions; _entries = entries; _aspects = aspects; + _students = students; _enrollments = enrollments; _grades = grades; + _grading = grading; _groupId = groupId; _schoolYear = schoolYear; + _gradingSystem = gradingSystem; + + _selectedPeriod = PeriodOptions[0]; + LoadAspectWeights(); + Recompute(); + } + + // 3.2.1: Gewichtung je Aspekt konfigurierbar — direkt hier, da es noch keine eigene + // Aspekt-Verwaltung (3.1) gibt. Änderungen fließen sofort in die Vorschau ein. + private void LoadAspectWeights() + { + var defaults = _aspects.GetDefaults(); + var specific = _aspects.GetByGroup(_groupId); + var all = defaults.Concat(specific).ToList(); + if (all.Count == 0) all = DefaultParticipationAspects.All.ToList(); + + foreach (var a in all) + { + var item = new AspectWeightItem(a, _aspects); + item.OnChanged = Recompute; + AspectWeights.Add(item); + } + } + + partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute(); + + private void Recompute() + { + Rows.Clear(); + StatusMessage = ""; + + var aspectWeights = AspectWeights.ToDictionary(a => a.Key, a => a.Weight); + + var sessions = _sessions.GetByGroup(_groupId) + .Where(s => InPeriod(s.Date, SelectedPeriod.Period)) + .OrderBy(s => s.Date) + .ToList(); + + var studentList = _students.GetByGroup(_groupId, _schoolYear); + var enrollmentList = _enrollments.GetByGroupAndYear(_groupId, _schoolYear); + + foreach (var student in studentList.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) + { + var enrollment = enrollmentList.FirstOrDefault(e => e.StudentId == student.Id); + var relevantSessions = sessions + .Where(s => enrollment is null || IsEnrolledAtDate(enrollment, s.Date)) + .ToList(); + + var points = new List<(DateOnly Date, double Rating)>(); + foreach (var session in relevantSessions) + { + var entry = _entries.GetBySessionAndStudent(session.Id, student.Id); + if (entry is null || entry.Ratings.Count == 0) continue; + + var weightSum = 0.0; + var valueSum = 0.0; + foreach (var r in entry.Ratings) + { + var w = aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; + if (w <= 0) continue; + valueSum += r.Value * w; + weightSum += w; + } + if (weightSum > 0) points.Add((session.Date, valueSum / weightSum)); + } + + Rows.Add(new ParticipationGradeRow(student.Id, student.FullName, points, _grading, _gradingSystem)); + } + } + + // 3.2.4: Übernahme als Grade (Category = Participation). Eine bereits übernommene + // Note für denselben Zeitraum wird aktualisiert statt dupliziert (erkannt am Note-Tag). + [RelayCommand] + private void Apply() + { + var noteTag = $"Mitarbeit {SelectedPeriod.Label} {_schoolYear}"; + var applied = 0; + foreach (var row in Rows) + { + if (row.Grade is null) continue; + + var grade = _grades.GetByStudentAndGroup(row.StudentId, _groupId) + .FirstOrDefault(g => g.Category == GradeCategory.Participation && g.Note == noteTag) + ?? new Grade + { + StudentId = row.StudentId, + GroupId = _groupId, + SchoolYear = _schoolYear, + Category = GradeCategory.Participation, + Note = noteTag, + }; + grade.Value = row.Grade; + grade.Date = DateOnly.FromDateTime(DateTime.Today); + _grades.Save(grade); + applied++; + } + StatusMessage = applied == 0 + ? "Keine Schüler mit Bewertungen im gewählten Zeitraum." + : $"{applied} Mitarbeitsnote(n) übernommen."; + } + + private static bool InPeriod(DateOnly date, ParticipationPeriod period) => period switch + { + ParticipationPeriod.H1 => date.Month >= 8 || date.Month <= 1, + ParticipationPeriod.H2 => date.Month >= 2 && date.Month <= 7, + _ => true, + }; + + private static bool IsEnrolledAtDate(Enrollment e, DateOnly date) => e.Period switch + { + EnrollmentPeriod.H1Only => date.Month >= 8 || date.Month <= 1, + EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7, + EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value) + && (e.LeftAt is null || date <= e.LeftAt.Value), + _ => true, + }; +} + +// ── Gewichtung eines Aspekts (3.2.1) ───────────────────────────────────────── + +public partial class AspectWeightItem : ObservableObject +{ + private readonly ParticipationAspect _aspect; + private readonly IParticipationAspectRepository _repo; + + public string Key => _aspect.Key; + public string Label => _aspect.Label; + + [ObservableProperty] private double _weight; + + public Action? OnChanged { get; set; } + + public AspectWeightItem(ParticipationAspect aspect, IParticipationAspectRepository repo) + { + _aspect = aspect; + _repo = repo; + _weight = aspect.Weight; + } + + partial void OnWeightChanged(double value) + { + _aspect.Weight = value; + _repo.Save(_aspect); + OnChanged?.Invoke(); + } +} + +public enum ParticipationPeriod { FullYear, H1, H2 } + +public class ParticipationPeriodOption(ParticipationPeriod period, string label) +{ + public ParticipationPeriod Period { get; } = period; + public string Label { get; } = label; + public override string ToString() => Label; +} + +// ── Zeile: berechnete Mitarbeitsnote pro Schüler ───────────────────────────── + +public class ParticipationGradeRow +{ + public Guid StudentId { get; } + public string Name { get; } + public string AverageDisplay { get; } + public string? Grade { get; } + public string GradeDisplay { get; } + public string TrendSymbol { get; } + public int SessionCount { get; } + + public ParticipationGradeRow(Guid studentId, string name, List<(DateOnly Date, double Rating)> points, + GradingService grading, GradingSystem system) + { + StudentId = studentId; + Name = name; + SessionCount = points.Count; + + if (points.Count == 0) + { + // 3.2.3: nicht bewertet ≠ schlecht bewertet — kein Grade-Wert, klar erkennbar. + AverageDisplay = "–"; + Grade = null; + GradeDisplay = "nicht bewertet"; + TrendSymbol = ""; + return; + } + + var average = points.Average(p => p.Rating); + AverageDisplay = average.ToString("0.00", CultureInfo.InvariantCulture); + Grade = grading.ParticipationGrade(average, system); + GradeDisplay = Grade; + TrendSymbol = ComputeTrend(points); + } + + // 3.2.5: einfache Trendanzeige — Vergleich erste vs. zweite Hälfte der Sitzungen. + private static string ComputeTrend(List<(DateOnly Date, double Rating)> points) + { + if (points.Count < 2) return ""; + var mid = points.Count / 2; + var first = points.Take(mid).Average(p => p.Rating); + var second = points.Skip(mid).Average(p => p.Rating); + var diff = second - first; + if (diff >= 0.4) return "↑"; + if (diff <= -0.4) return "↓"; + return "→"; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index ba25e04..89013c1 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -23,6 +23,11 @@ public partial class ParticipationTabViewModel : ObservableObject private string _schoolYear = ""; private Guid? _subjectId; private int _gradeLevel; + private GradingSystem _gradingSystem; + + public Guid GroupId => _groupId; + public string SchoolYear => _schoolYear; + public GradingSystem GradingSystem => _gradingSystem; [ObservableProperty] private ParticipationSessionItem? _selectedSession; [ObservableProperty] private string _noDataText = "Keine Bewertungssitzung ausgewählt."; @@ -41,6 +46,7 @@ public partial class ParticipationTabViewModel : ObservableObject public Func>? OnAddSession { get; set; } public Func? OnQuickInput { get; set; } + public Func? OnComputeGrade { get; set; } public ParticipationTabViewModel( IParticipationSessionRepository sessions, @@ -65,6 +71,7 @@ public partial class ParticipationTabViewModel : ObservableObject var group = _groups.GetById(groupId); _subjectId = group?.SubjectId; _gradeLevel = group?.GradeLevel ?? 0; + _gradingSystem = group?.GradingSystem ?? GradingSystem.Grades1To6; HasCompetencyCatalog = _subjectId.HasValue && _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel).Count > 0; @@ -248,6 +255,13 @@ public partial class ParticipationTabViewModel : ObservableObject private bool CanQuickInput() => SelectedSession is not null && StudentRows.Count > 0; + [RelayCommand] + private async Task ComputeGrade() + { + if (OnComputeGrade is null) return; + await OnComputeGrade(this); + } + [RelayCommand] private void DeleteSession() { diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml b/LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml new file mode 100644 index 0000000..24cf44d --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +