Mitarbeitsnote-Aggregation (3.2)

Neuer Dialog berechnet aus den Sitzungs-Bewertungen je Schüler eine
Mitarbeitsnote (H1/H2/Gesamtjahr), mit editierbarer Aspekt-Gewichtung,
Trendanzeige und Übernahme als Grade (Category=Participation).
This commit is contained in:
2026-08-12 14:10:04 +02:00
parent 482942ce09
commit 182c386cc5
9 changed files with 392 additions and 7 deletions
@@ -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<ParticipationPeriodOption> PeriodOptions { get; } =
[
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
];
public ObservableCollection<ParticipationGradeRow> Rows { get; } = [];
public ObservableCollection<AspectWeightItem> 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 "→";
}
}
@@ -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<Task<ParticipationSession?>>? OnAddSession { get; set; }
public Func<ParticipationTabViewModel, Task>? OnQuickInput { get; set; }
public Func<ParticipationTabViewModel, Task>? 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()
{