CI / build-and-test (push) Canceled after 0s
Neuer Assistent (Einstieg im Zeugnisnoten-Dialog) geht die Lerngruppe Schüler für Schüler durch: berechnete Note mit ungerundetem Wert und Teilbereichen, Mitarbeitsgraph abschnittsweise oder als Verlauf, Arbeiten und Einzelnoten. Das 1. Halbjahr erscheint im 2. Halbjahr nur als Kontext. Die Endjahresnote wird jetzt in Dialog und Assistent aus den Halbjahresnoten verrechnet (gemeinsamer ReportGradeCalculator). In Klasse 10 kommen Projektprüfung, schriftliche/mündliche Abschlussprüfung und zusätzliche mündliche Prüfung hinzu, gewichtet nach Oberschulverordnung und PrüfV Sek. I; gerundet wird dabei bewusst nicht automatisch. Die Zeitleisten-Erzeugung des Mitarbeits-Assistenten liegt dafür im gemeinsamen ParticipationTimelineBuilder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
12 KiB
C#
252 lines
12 KiB
C#
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 IStudentRepository _students;
|
||
private readonly IGroupMembershipRepository _memberships;
|
||
private readonly IReportGradeRepository _reportGrades;
|
||
private readonly ReportGradeCalculator _calculator;
|
||
private readonly IParticipationSessionRepository _participationSessions;
|
||
private readonly IParticipationRepository _participation;
|
||
private readonly AttendanceBalanceService _attendanceBalance;
|
||
private readonly Guid _groupId;
|
||
private readonly string _groupLabel;
|
||
|
||
[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)
|
||
{
|
||
_students = students; _memberships = memberships; _reportGrades = reportGrades;
|
||
_participationSessions = participationSessions; _participation = participation;
|
||
_attendanceBalance = attendanceBalance;
|
||
_groupId = groupId; _groupLabel = groupLabel;
|
||
_calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
|
||
memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId), grading,
|
||
groupId, groupType, gradingSystem, schoolYear);
|
||
|
||
_selectedPeriod = PeriodOptions[0];
|
||
Recompute();
|
||
}
|
||
|
||
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
|
||
partial void OnRoundingRuleChanged(RoundingRule value) => Recompute();
|
||
|
||
public void Refresh() => Recompute();
|
||
|
||
private void Recompute()
|
||
{
|
||
_calculator.Reload();
|
||
SchemeSummary = _calculator.SchemeSummary;
|
||
|
||
var period = SelectedPeriod.Period;
|
||
var periodTag = SelectedPeriod.Label;
|
||
var (periodFrom, periodTo) = _calculator.Range(period);
|
||
|
||
var students = _students.GetByGroup(_groupId);
|
||
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Halbjahre aus den Einzelleistungen, das Schuljahr aus den Halbjahresnoten
|
||
// (plus ggf. Prüfungsnoten) — siehe ReportGradeCalculator.
|
||
var calculated = _calculator.Calculate(student.Id, period, RoundingRule).Rounded;
|
||
|
||
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));
|
||
}
|