Notenverwaltung (Kapitel 2) und Mitarbeits-Assistent

Notenübersicht der Gruppe (Matrix, Gesamt-Spalte, Sortierung, Halbjahresfilter),
Einzelnoten-Pflege samt Sammelerfassung, Gewichtungsschema mit Voreinstellung je
Gruppentyp, Zeugnisnoten-Berechnung mit Übersteuern/Festschreiben/Export und
Notenentwicklung im Schülerdetail.

Dazu Anwesenheits-/Hausaufgaben-Tracking je Mitarbeit-Sitzung, ein neuer
Mitarbeits-Assistent (Zeitleiste mit Abschnitten, automatischer Notenvorschlag,
Zusammenzug zur Halbjahresnote) und eine Dashboard-Kachel für offene
Entschuldigungen. Außerdem: verbliebene englische Begriffe in Auswahlfeldern
und Buttons auf Deutsch umgestellt.
This commit is contained in:
2026-08-12 19:19:22 +02:00
parent a1e722ace1
commit febcb0855a
34 changed files with 2503 additions and 39 deletions
@@ -0,0 +1,461 @@
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)
{
if (string.IsNullOrWhiteSpace(item.Value)) { item.ValidationMessage = "Wert darf nicht leer sein."; return; }
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out _))
{ item.ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
item.ValidationMessage = "";
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 _validationMessage = "";
// 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 _validationMessage = "";
// 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))
{ ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
ValidationMessage = "";
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 = "";
}