Files
adminandClaude Sonnet 5 1215d4ac22
CI / build-and-test (push) Canceled after 0s
Tagesflagge im Sitzplan, Leistungsüberblick mit Zielnoten-Rechner
- Mitarbeit: Tagesflagge (👑 Spitzentag / 😴 Schlaftag /  Schlechter Tag)
  je Schüler und Sitzung. Primär im Sitzplatz-Dialog (⇧1/2/3, ⇧X) mit
  Badge auf der Sitzplatz-Kachel; Fallback in der Schnelleingabe für
  Lerngruppen ohne Sitzplan.
- Noten: neuer Dialog "Überblick" in der Notenübersicht zeigt Klausuren,
  Mitarbeit- und sonstige Noten eines Schülers samt berechneter
  Zeugnisnote. Zielnoten-Rechner (ReportGradeTargetCalculator) beantwortet
  "was brauche ich noch für Note X", ein Was-wäre-wenn-Rechner simuliert
  eine zusätzliche Klausurnote. Bewerter-/Schülermodus per Umschalter im
  selben Fenster — Bewertermodus zeigt zusätzlich Kursdurchschnitt je
  Klausur und erlaubt das Bearbeiten von Mitarbeit-/Sonstige-Noten.
  Klausurverlauf als Balken-Sparkline wie die bestehende Notenentwicklung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:43:47 +02:00

503 lines
18 KiB
C#
Raw Permalink 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 string _schoolYear = "";
private bool _sortByTotal;
private bool _sortDescending;
public Guid GroupId => _groupId;
public GradingSystem GradingSystem => _gradingSystem;
public GroupType GroupType => _groupType;
public string GroupLabel => _groupLabel;
public string SchoolYear => _schoolYear;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private bool _showAsPoints = true;
[ObservableProperty] private GradeOverviewRow? _selectedRow;
[ObservableProperty] private int _rebuildColumnsSignal;
[ObservableProperty] private bool _isReadOnly;
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 Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { 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, string schoolYear, bool isReadOnly = false)
{
_groupId = groupId;
_gradingSystem = gradingSystem;
_groupType = groupType;
_groupLabel = groupLabel;
_schoolYear = schoolYear;
IsReadOnly = isReadOnly;
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 ShowPerformanceOverview()
{
if (SelectedRow is null || OnShowPerformanceOverview is null) return;
await OnShowPerformanceOverview(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 (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
{
ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
_ => SchoolYearPeriodKind.FullYear,
});
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
var exams = _exams.GetByGroup(_groupId)
.Where(e => e.Date >= periodFrom && e.Date <= periodTo)
.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 => g.Date >= periodFrom && g.Date <= periodTo)
.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 (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var cells = new List<string>();
var numeric = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date))
{ cells.Add(""); continue; }
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)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, key.Date))
{ cells.Add(""); continue; }
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);
}
}
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 IGroupMembershipRepository _memberships;
private readonly List<Student> _students;
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,
IGroupMembershipRepository memberships, Guid groupId)
{
_grades = grades; _memberships = memberships; _groupId = groupId;
_students = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
RebuildRows(DateOnly.FromDateTime(DateTime.Today));
}
partial void OnDateTextChanged(string value)
{
if (DateOnly.TryParseExact(value, "dd.MM.yyyy", null,
System.Globalization.DateTimeStyles.None, out var date))
RebuildRows(date);
}
private void RebuildRows(DateOnly date)
{
var previousValues = Rows.ToDictionary(r => r.StudentId, r => r.Value);
var membershipByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
Rows.Clear();
foreach (var student in _students)
{
if (membershipByStudent.TryGetValue(student.Id, out var membership)
&& !GroupMembershipService.IsActiveOn(membership, date))
continue;
Rows.Add(new CollectiveGradeStudentRow(student.Id, student.FullName)
{
Value = previousValues.GetValueOrDefault(student.Id, ""),
});
}
}
[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 = "";
}