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:
@@ -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 = "";
|
||||
}
|
||||
Reference in New Issue
Block a user