Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
T
adminandClaude Sonnet 5 db1afff7e4 Fehlerbehandlung, Logging und feldbezogene Validierung (Kapitel 13.2)
Zentrale Exception-Behandlung (Dispatcher.UIThread.UnhandledException,
AppDomain, TaskScheduler) verhindert Abstürze und protokolliert Fehler
über AppLogger in eine rotierende Log-Datei im App-Datenverzeichnis.
Toast-Benachrichtigungen zeigen Erfolg/Fehler global an. Alle Dialoge
mit Formularfeldern zeigen Validierungsmeldungen jetzt direkt am
betroffenen Feld statt in einem Sammel-Label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:38:30 +02:00

468 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// ── Notenübersicht der Gruppe (2.1) ──────────────────────────────────────────
public partial class GradeOverviewTabViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly GradingService _grading;
private Guid _groupId;
private GradingSystem _gradingSystem;
private GroupType _groupType;
private string _groupLabel = "";
private bool _sortByTotal;
private bool _sortDescending;
public Guid GroupId => _groupId;
public GradingSystem GradingSystem => _gradingSystem;
public GroupType GroupType => _groupType;
public string GroupLabel => _groupLabel;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private bool _showAsPoints = true;
[ObservableProperty] private GradeOverviewRow? _selectedRow;
[ObservableProperty] private int _rebuildColumnsSignal;
public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15;
public List<ParticipationPeriodOption> PeriodOptions { get; } =
[
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
];
public ObservableCollection<GradeOverviewColumnDef> Columns { get; } = [];
public ObservableCollection<GradeOverviewRow> Rows { get; } = [];
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
public Func<Task>? OnCollectiveGrade { get; set; }
public Func<Task>? OnReportGrades { get; set; }
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students,
IGroupMembershipRepository memberships, GradingService grading)
{
_grades = grades; _exams = exams; _results = results;
_students = students; _memberships = memberships; _grading = grading;
_selectedPeriod = PeriodOptions[0];
}
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType, string groupLabel)
{
_groupId = groupId;
_gradingSystem = gradingSystem;
_groupType = groupType;
_groupLabel = groupLabel;
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
OnPropertyChanged(nameof(CanTogglePointsView));
Recompute();
}
public void Refresh() => Recompute();
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
partial void OnShowAsPointsChanged(bool value) => Recompute();
[RelayCommand]
private void SortByName()
{
if (!_sortByTotal) _sortDescending = !_sortDescending;
else { _sortByTotal = false; _sortDescending = false; }
ApplySort();
}
[RelayCommand]
private void SortByTotal()
{
if (_sortByTotal) _sortDescending = !_sortDescending;
else { _sortByTotal = true; _sortDescending = false; }
ApplySort();
}
[RelayCommand]
private async Task ManageStudentGrades()
{
if (SelectedRow is null || OnManageStudentGrades is null) return;
await OnManageStudentGrades(SelectedRow);
Recompute();
}
[RelayCommand]
private async Task CollectiveGrade()
{
if (OnCollectiveGrade is null) return;
await OnCollectiveGrade();
Recompute();
}
[RelayCommand]
private async Task ReportGrades()
{
if (OnReportGrades is null) return;
await OnReportGrades();
}
private void Recompute()
{
var period = SelectedPeriod.Period;
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
var exams = _exams.GetByGroup(_groupId)
.Where(e => InPeriod(e.Date, period))
.OrderBy(e => e.Date)
.ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var otherGrades = _grades.GetByGroup(_groupId)
.Where(g => InPeriod(g.Date, period))
.ToList();
var gradeColumnKeys = otherGrades
.Select(g => (g.Category, Note: g.Note ?? "", g.Date))
.Distinct()
.OrderBy(k => k.Date)
.ToList();
Columns.Clear();
foreach (var exam in exams)
Columns.Add(new GradeOverviewColumnDef($"{exam.Date:dd.MM.} {exam.Title}"));
foreach (var key in gradeColumnKeys)
{
var header = string.IsNullOrWhiteSpace(key.Note)
? $"{GradeCategoryDisplay.Label(key.Category)} {key.Date:dd.MM.}"
: key.Note;
Columns.Add(new GradeOverviewColumnDef(header));
}
Rows.Clear();
foreach (var student in students)
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (!StudentActiveInPeriod(membership, period)) continue;
var cells = new List<string>();
var numeric = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) { cells.Add(""); continue; }
resultsByExam[exam.Id].TryGetValue(student.Id, out var result);
if (result is null) { cells.Add(""); continue; }
if (result.Absent) { cells.Add("abwesend"); continue; }
var display = FormatValue(result.Grade);
cells.Add(display);
if (result.Grade is not null) numeric.Add((result.Grade, 1.0));
}
foreach (var key in gradeColumnKeys)
{
var grade = otherGrades.FirstOrDefault(g =>
g.StudentId == student.Id && g.Category == key.Category &&
(g.Note ?? "") == key.Note && g.Date == key.Date);
if (grade is null) { cells.Add(""); continue; }
cells.Add(FormatValue(grade.Value));
numeric.Add((grade.Value, grade.Weight));
}
var total = numeric.Count == 0 ? (double?)null : _grading.WeightedAverage(numeric);
var totalDisplay = total is null ? "" : total.Value.ToString("0.00", CultureInfo.InvariantCulture);
Rows.Add(new GradeOverviewRow(student.Id, student.FullName, cells, totalDisplay, total));
}
ApplySort();
RebuildColumnsSignal++;
}
private string FormatValue(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "";
if (!ShowAsPoints && _gradingSystem == GradingSystem.Points0To15 && int.TryParse(raw, out var points))
return PointsNoteMapping.PointsToNote(points);
return raw;
}
private void ApplySort()
{
var sorted = _sortByTotal
? Rows.OrderBy(r => r.TotalSortValue is null).ThenBy(r => r.TotalSortValue).ToList()
: Rows.OrderBy(r => r.Name).ToList();
if (_sortDescending) sorted.Reverse();
for (var i = 0; i < sorted.Count; i++) Rows.Move(Rows.IndexOf(sorted[i]), i);
}
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 StudentActiveInPeriod(GroupMembership? m, ParticipationPeriod period)
{
if (period == ParticipationPeriod.FullYear || m is null) return true;
return m.Period switch
{
MembershipPeriod.H1Only => period == ParticipationPeriod.H1,
MembershipPeriod.H2Only => period == ParticipationPeriod.H2,
_ => true,
};
}
}
public class GradeOverviewColumnDef(string header)
{
public string Header { get; } = header;
}
public class GradeOverviewRow(Guid studentId, string name, List<string> cells, string totalDisplay, double? totalSortValue)
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
public List<string> Cells { get; } = cells;
public string TotalDisplay { get; } = totalDisplay;
public double? TotalSortValue { get; } = totalSortValue;
}
// ── Kategorie-Anzeige & Punkte/Noten-Umrechnung ──────────────────────────────
public static class GradeCategoryDisplay
{
public static string Label(GradeCategory c) => c switch
{
GradeCategory.Oral => "Mündlich",
GradeCategory.Homework => "Hausaufgaben",
GradeCategory.Participation => "Mitarbeit",
GradeCategory.Project => "Projekt",
GradeCategory.Other => "Sonstiges",
_ => c.ToString(),
};
public static string[] Options { get; } = Enum.GetValues<GradeCategory>().Select(Label).ToArray();
public static GradeCategory FromLabel(string? label) =>
Enum.GetValues<GradeCategory>().FirstOrDefault(c => Label(c) == label, GradeCategory.Other);
}
public static class PointsNoteMapping
{
// Grobe, standardübliche Punkte-Noten-Umrechnung (Oberstufe), nur für die Anzeige.
public static string PointsToNote(int points) => points switch
{
>= 13 => "1",
>= 10 => "2",
>= 7 => "3",
>= 4 => "4",
>= 1 => "5",
_ => "6",
};
}
// ── Note hinzufügen/bearbeiten für einen Schüler (2.2.1, 2.2.2) ──────────────
public partial class StudentGradesDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly Guid _studentId;
private readonly Guid _groupId;
public string StudentName { get; }
public ObservableCollection<GradeEditItem> Entries { get; } = [];
public StudentGradesDialogViewModel(IGradeRepository grades, Guid studentId, Guid groupId, string studentName)
{
_grades = grades; _studentId = studentId; _groupId = groupId;
StudentName = studentName;
Load();
}
private void Load()
{
Entries.Clear();
foreach (var g in _grades.GetByStudentAndGroup(_studentId, _groupId).OrderByDescending(g => g.Date))
Entries.Add(new GradeEditItem(g) { OnSave = Save, OnDelete = Delete });
}
[RelayCommand]
private void AddEntry()
{
var item = new GradeEditItem(new Grade
{
StudentId = _studentId,
GroupId = _groupId,
Category = GradeCategory.Other,
Date = DateOnly.FromDateTime(DateTime.Today),
})
{ OnSave = Save, OnDelete = Delete, IsNew = true };
Entries.Insert(0, item);
}
private void Save(GradeEditItem item)
{
item.ValueError = ""; item.DateTextError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(item.Value)) { item.ValueError = "Wert darf nicht leer sein."; valid = false; }
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out _))
{ item.DateTextError = "Format TT.MM.JJJJ."; valid = false; }
if (!valid) return;
var grade = item.ToModel();
_grades.Save(grade);
item.IsNew = false;
item.MarkSaved(grade.CreatedAt);
}
private void Delete(GradeEditItem item)
{
if (!item.IsNew) _grades.Delete(item.Id);
Entries.Remove(item);
}
}
public partial class GradeEditItem : ObservableObject
{
public Guid Id { get; }
private readonly Guid _studentId;
private readonly Guid _groupId;
public bool IsNew { get; set; }
[ObservableProperty] private GradeCategory _category;
[ObservableProperty] private string _value;
[ObservableProperty] private string _dateText;
[ObservableProperty] private double _weight;
[ObservableProperty] private string? _note;
[ObservableProperty] private string _valueError = "";
[ObservableProperty] private string _dateTextError = "";
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string CategoryName
{
get => GradeCategoryDisplay.Label(Category);
set => Category = GradeCategoryDisplay.FromLabel(value);
}
public string CreatedAtDisplay { get; private set; }
public Action<GradeEditItem>? OnSave { get; set; }
public Action<GradeEditItem>? OnDelete { get; set; }
public GradeEditItem(Grade g)
{
Id = g.Id;
_studentId = g.StudentId;
_groupId = g.GroupId;
_category = g.Category;
_value = g.Value;
_dateText = g.Date.ToString("dd.MM.yyyy");
_weight = g.Weight;
_note = g.Note;
CreatedAtDisplay = $"erfasst am {g.CreatedAt.ToLocalTime():dd.MM.yyyy HH:mm}";
}
public void MarkSaved(DateTime createdAt) => CreatedAtDisplay = $"erfasst am {createdAt.ToLocalTime():dd.MM.yyyy HH:mm}";
public Grade ToModel() => new()
{
Id = Id,
StudentId = _studentId,
GroupId = _groupId,
Category = Category,
Value = Value.Trim(),
Date = DateOnly.ParseExact(DateText, "dd.MM.yyyy"),
Weight = Weight,
Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(),
};
[RelayCommand]
private void Save() => OnSave?.Invoke(this);
[RelayCommand]
private void Delete() => OnDelete?.Invoke(this);
}
// ── Sammelerfassung für die ganze Gruppe (2.2.3) ─────────────────────────────
public partial class CollectiveGradeDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly Guid _groupId;
[ObservableProperty] private GradeCategory _category = GradeCategory.Other;
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private double _weight = 1.0;
[ObservableProperty] private string? _note;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private string _dateTextError = "";
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string CategoryName
{
get => GradeCategoryDisplay.Label(Category);
set => Category = GradeCategoryDisplay.FromLabel(value);
}
public ObservableCollection<CollectiveGradeStudentRow> Rows { get; } = [];
public CollectiveGradeDialogViewModel(IGradeRepository grades, IStudentRepository students, Guid groupId)
{
_grades = grades; _groupId = groupId;
foreach (var s in students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
Rows.Add(new CollectiveGradeStudentRow(s.Id, s.FullName));
}
[RelayCommand]
private void SaveAll()
{
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
{ DateTextError = "Format TT.MM.JJJJ."; return; }
DateTextError = "";
var count = 0;
foreach (var row in Rows)
{
if (string.IsNullOrWhiteSpace(row.Value)) continue;
_grades.Save(new Grade
{
StudentId = row.StudentId,
GroupId = _groupId,
Category = Category,
Value = row.Value.Trim(),
Date = date,
Weight = Weight,
Note = string.IsNullOrWhiteSpace(Note) ? null : Note.Trim(),
});
count++;
}
StatusMessage = count == 0
? "Keine Werte eingegeben."
: $"{count} Note(n) gespeichert.";
}
}
public partial class CollectiveGradeStudentRow(Guid studentId, string name) : ObservableObject
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
[ObservableProperty] private string _value = "";
}