Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs

288 lines
14 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 LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Zeugnisnote (2.4) ─────────────────────────────────────────────────────────
public partial class ReportGradeDialogViewModel : ObservableObject
{
private readonly IGradeRepository _grades;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IGradingSchemeRepository _schemes;
private readonly IReportGradeRepository _reportGrades;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly GroupType _groupType;
private readonly GradingSystem _gradingSystem;
private readonly string _groupLabel;
private readonly string _schoolYear;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
[ObservableProperty] private string _schemeSummary = "";
public string GroupLabel => _groupLabel;
public List<ParticipationPeriodOption> PeriodOptions { get; } =
[
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
];
public string[] RoundingOptions { get; } = RoundingRuleDisplay.Options;
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
public string RoundingRuleName
{
get => RoundingRuleDisplay.Label(RoundingRule);
set => RoundingRule = RoundingRuleDisplay.FromLabel(value);
}
public ObservableCollection<ReportGradeRow> Rows { get; } = [];
public ReportGradeDialogViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
IParticipationSessionRepository participationSessions, IParticipationRepository participation,
AttendanceBalanceService attendanceBalance, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
{
_grades = grades; _exams = exams; _results = results; _students = students;
_memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
_participationSessions = participationSessions; _participation = participation;
_attendanceBalance = attendanceBalance;
_groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
_schoolYear = schoolYear;
_selectedPeriod = PeriodOptions[0];
Recompute();
}
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
partial void OnRoundingRuleChanged(RoundingRule value) => Recompute();
private GradingScheme ResolveScheme() =>
_schemes.GetByGroup(_groupId)
?? _schemes.GetDefaultForType(_groupType)
?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
private void Recompute()
{
var scheme = ResolveScheme();
SchemeSummary = $"Klausuren {scheme.ExamsPercent:0.#} % · Mitarbeit {scheme.ParticipationPercent:0.#} % · " +
$"Sonstige {scheme.OtherPercent:0.#} %";
var period = SelectedPeriod.Period;
var periodTag = SelectedPeriod.Label;
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).ToList();
var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList();
// Fehlquote (Nutzer-Feedback): je Schüler die Anwesenheits-Bilanz im gewählten Zeitraum,
// nur aus Sitzungen dieser Gruppe (anders als StudentDetailViewModel.LoadAttendanceBalance,
// das gruppenübergreifend über den ganzen Schüler rechnet) — hier zählt nur, was für diese
// Zeugnisnote relevant ist.
var sessionsInPeriod = _participationSessions.GetByGroup(_groupId)
.Where(s => s.Date >= periodFrom && s.Date <= periodTo).ToList();
var attendanceByStudent = new Dictionary<Guid, List<(DateOnly Date, AttendanceStatus? Status)>>();
foreach (var session in sessionsInPeriod)
foreach (var entry in _participation.GetBySession(session.Id))
{
if (!attendanceByStudent.TryGetValue(entry.StudentId, out var list))
attendanceByStudent[entry.StudentId] = list = [];
list.Add((session.Date, entry.Attendance));
}
Rows.Clear();
foreach (var student in students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
membershipsByStudent.TryGetValue(student.Id, out var membership);
if (membership is not null && !GroupMembershipService.Overlaps(membership, periodFrom, periodTo)) continue;
var absenceRate = _attendanceBalance.Calculate(
attendanceByStudent.TryGetValue(student.Id, out var entries) ? entries : [],
periodFrom, periodTo).AbsenceRatePercent;
var existing = _reportGrades.GetByStudentGroupPeriod(student.Id, _groupId, periodTag);
if (existing is { IsLocked: true })
{
Rows.Add(ReportGradeRow.FromLocked(student.Id, student.FullName, existing, absenceRate, Save, ToggleLock));
continue;
}
var examGrades = new List<(string Grade, double Weight)>();
foreach (var exam in exams)
{
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
if (result.Absent || result.Grade is null) continue;
examGrades.Add((result.Grade, 1.0));
}
var participationGrades = allGrades
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
&& (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
.Select(g => (g.Value, g.Weight)).ToList();
var otherGrades = allGrades
.Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation
&& (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
.Select(g => (g.Value, g.Weight)).ToList();
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
scheme, _gradingSystem, RoundingRule);
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, absenceRate, Save, ToggleLock));
}
}
private void Save(ReportGradeRow row)
{
if (!string.IsNullOrWhiteSpace(row.OverrideValue) && string.IsNullOrWhiteSpace(row.OverrideReason))
{
row.ValidationMessage = "Für ein manuelles Übersteuern ist eine Begründung Pflicht.";
return;
}
row.ValidationMessage = "";
var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label)
?? new ReportGrade { StudentId = row.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label };
record.CalculatedValue = row.CalculatedValue ?? "";
record.OverrideValue = string.IsNullOrWhiteSpace(row.OverrideValue) ? null : row.OverrideValue.Trim();
record.OverrideReason = string.IsNullOrWhiteSpace(row.OverrideReason) ? null : row.OverrideReason.Trim();
_reportGrades.Save(record);
row.MarkSaved();
}
private void ToggleLock(ReportGradeRow row)
{
var record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label);
if (record is null)
{
if (!row.IsLocked)
{
// Festschreiben ohne vorherigen Save: aktuellen Stand zuerst sichern.
Save(row);
record = _reportGrades.GetByStudentGroupPeriod(row.StudentId, _groupId, SelectedPeriod.Label);
if (record is null) return;
}
else return;
}
record.IsLocked = !record.IsLocked;
_reportGrades.Save(record);
Recompute();
}
public string ExportCsv()
{
var csv = new CsvBuilder()
.AddRow("Zeugnisnoten", _groupLabel, SelectedPeriod.Label)
.AddRow("Schüler", "Berechnet", "Übersteuert", "Begründung", "Endnote", "Gesperrt");
foreach (var r in Rows)
csv.AddRow(r.Name, r.CalculatedValue, r.OverrideValue, r.OverrideReason,
r.FinalDisplay, r.IsLocked ? "ja" : "");
return csv.ToString();
}
}
// ── Rundungsregel-Anzeige ─────────────────────────────────────────────────────
public static class RoundingRuleDisplay
{
public static string Label(RoundingRule r) => r switch
{
RoundingRule.Commercial => "Kaufmännisch",
RoundingRule.Pedagogical => "Pädagogisch",
_ => r.ToString(),
};
public static string[] Options { get; } = [Label(RoundingRule.Commercial), Label(RoundingRule.Pedagogical)];
public static RoundingRule FromLabel(string? label) =>
label == Label(RoundingRule.Pedagogical) ? RoundingRule.Pedagogical : RoundingRule.Commercial;
}
// ── Zeile: Zeugnisnote eines Schülers ────────────────────────────────────────
public partial class ReportGradeRow : ObservableObject
{
/// Nutzer-Vorgabe: ab dieser Fehlquote darf unabhängig von der fachlichen Leistung eine 5
/// vergeben werden — reine Information/Hervorhebung, kein automatisches Übersteuern der
/// berechneten Note.
public const double HighAbsenceThresholdPercent = 50.0;
public Guid StudentId { get; }
public string Name { get; }
public string? CalculatedValue { get; private set; }
public bool IsLocked { get; private set; }
public double AbsenceRatePercent { get; }
[ObservableProperty] private string? _overrideValue;
[ObservableProperty] private string? _overrideReason;
[ObservableProperty] private string _validationMessage = "";
public string CalculatedDisplay => CalculatedValue ?? "";
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue! : CalculatedDisplay;
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
public string AbsenceRateDisplay => $"{AbsenceRatePercent:0.#} % gefehlt";
public bool HasHighAbsenceRate => AbsenceRatePercent >= HighAbsenceThresholdPercent;
// Rot wie AttendanceDisplay.Color(Truant) — dieselbe Warnfarbe wie im Mitarbeit-Feature.
public string AbsenceRateColorHex => HasHighAbsenceRate ? "#D64545" : "#8A8A8A";
public IRelayCommand SaveCommand { get; }
public IRelayCommand ToggleLockCommand { get; }
private ReportGradeRow(Guid studentId, string name, string? calculated, ReportGrade? existing,
double absenceRatePercent, bool locked, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock)
{
StudentId = studentId;
Name = name;
CalculatedValue = calculated;
IsLocked = locked;
AbsenceRatePercent = absenceRatePercent;
_overrideValue = existing?.OverrideValue;
_overrideReason = existing?.OverrideReason;
SaveCommand = new RelayCommand(() => onSave(this));
ToggleLockCommand = new RelayCommand(() => onToggleLock(this));
}
public static ReportGradeRow FromCalculated(Guid studentId, string name, string? calculated,
ReportGrade? existing, double absenceRatePercent,
Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, calculated, existing, absenceRatePercent, existing?.IsLocked ?? false, onSave, onToggleLock);
public static ReportGradeRow FromLocked(Guid studentId, string name, ReportGrade locked,
double absenceRatePercent, Action<ReportGradeRow> onSave, Action<ReportGradeRow> onToggleLock) =>
new(studentId, name, locked.CalculatedValue, locked, absenceRatePercent, true, onSave, onToggleLock);
public void MarkSaved()
{
OnPropertyChanged(nameof(FinalDisplay));
}
partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay));
}