Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs
T
adminandClaude Sonnet 5.5 651c9cddf8 Förderarbeit als dritte Folgearbeits-Art ergänzen
Neben Wiederholung und Nachschreibarbeit gibt es im Arbeitsdialog die Art
Förderarbeit (Exam.IsSupportWork). Sie darf am Tag der ersten Arbeit liegen,
übernimmt dessen Datum als Vorschlag und ersetzt wie eine Nachschreibarbeit
nur fehlende Noten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:55:27 +02:00

693 lines
33 KiB
C#
Raw 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;
// ── Zeugnisnoten-Assistent ───────────────────────────────────────────────────
//
// Geht die Lerngruppe Schüler für Schüler durch und zieht alles zusammen, was für die
// Halbjahres- bzw. Endjahresnote zählt: berechnete Note mit Teilbereichen, Mitarbeitsgraph
// (abschnittsweise blätterbar oder als durchgehender Verlauf), Arbeiten und Einzelnoten.
// Ergänzt den Zeugnisnoten-Dialog (2.4), ersetzt ihn nicht — beide schreiben dieselben
// ReportGrade-Datensätze (gleiche Period-Bezeichnungen), der Dialog bleibt die schnelle Liste.
//
// Halbjahre sind eigenständig: Leistungen aus dem 1. Halbjahr zählen nicht in die Note des
// 2. Halbjahrs, werden dort aber ausgegraut als Kontext gezeigt (Trends). Die Endjahresnote
// wird aus den Halbjahresnoten verrechnet, nicht aus allen Einzelleistungen; in Klasse 10 lassen
// sich dafür zusätzlich Projekt- und mündliche Prüfung eintragen (siehe ReportGradeCalculator).
public partial class ReportGradeAssistantViewModel : ObservableObject
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
private const int ExamGradeLevel = 10;
private readonly IReportGradeRepository _reportGrades;
private readonly IParticipationRepository _participation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly GradingService _grading;
private readonly ParticipationTimelineBuilder _timeline;
private readonly ReportGradeCalculator _calculator;
private readonly InterimGradeCalculator _interimCalculator;
private readonly IInterimGradeRepository _interimGrades;
private readonly InterimRunOptions? _interim;
private readonly Guid _groupId;
private readonly int _gradeLevel;
private readonly DateOnly _today;
private readonly List<Student> _allStudents;
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
private readonly List<ParticipationSection> _sections;
private string? _calculatedValue;
private bool _showing;
private List<SectionView> _sectionViews = [];
private int _sectionIndex;
private bool _loading;
public string GroupLabel { get; }
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
[ObservableProperty] private string _schemeSummary = "";
[ObservableProperty] private AssistantStudentItem? _selectedStudent;
[ObservableProperty] private bool _showPreviousHalf = true;
// Kopf: Note des gewählten Schülers
[ObservableProperty] private string _calculatedDisplay = "–";
[ObservableProperty] private string _calculationDetail = "";
[ObservableProperty] private string _borderlineHint = "";
[ObservableProperty] private string _absenceRateDisplay = "";
[ObservableProperty] private string _absenceRateColorHex = "#8A8A8A";
[ObservableProperty] private string? _overrideValue;
[ObservableProperty] private string? _overrideReason;
[ObservableProperty] private string _overrideReasonError = "";
[ObservableProperty] private bool _isLocked;
[ObservableProperty] private string _statusMessage = "";
// Klasse 10: Prüfungsnoten, die in der Endjahresnote wie ein weiteres Halbjahr zählen
[ObservableProperty] private string? _projectExamGrade;
[ObservableProperty] private string? _oralExamGrade;
[ObservableProperty] private string? _writtenExamGrade;
[ObservableProperty] private string? _additionalOralExamGrade;
[ObservableProperty] private string _projectExamGradeError = "";
[ObservableProperty] private string _oralExamGradeError = "";
[ObservableProperty] private string _writtenExamGradeError = "";
[ObservableProperty] private string _additionalOralExamGradeError = "";
// Mitarbeitsgraph
[ObservableProperty] private bool _isSectionMode = true;
[ObservableProperty] private double _timelineZoom = 1.0;
[ObservableProperty] private IReadOnlyList<WizardSectionGroup> _chartGroups = [];
[ObservableProperty] private string _sectionTitle = "";
[ObservableProperty] private string _sectionPosition = "";
public List<ParticipationPeriodOption> PeriodOptions { get; } =
[
new(ParticipationPeriod.H1, "1. Halbjahr"),
new(ParticipationPeriod.H2, "2. Halbjahr"),
new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
];
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<AssistantStudentItem> Students { get; } = [];
public ObservableCollection<AssistantTile> Tiles { get; } = [];
public ObservableCollection<AssistantWorkCard> Works { get; } = [];
public ObservableCollection<AssistantGradeRow> ParticipationGrades { get; } = [];
public ObservableCollection<AssistantGradeRow> OtherGrades { get; } = [];
public bool HasStudent => SelectedStudent is not null;
public string StudentName => SelectedStudent?.Name ?? "";
public string ProgressText => SelectedStudent is null
? "" : $"{Students.IndexOf(SelectedStudent) + 1} / {Students.Count}";
public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue!.Trim() : CalculatedDisplay;
public bool IsContinuousMode => !IsSectionMode;
/// Nur im 2. Halbjahr gibt es ein Vorhalbjahr, das als Kontext eingeblendet werden kann.
public bool CanShowPreviousHalf => SelectedPeriod.Period == ParticipationPeriod.H2;
/// Projekt- und mündliche Prüfung gibt es nur in Klasse 10 und nur für die Endjahresnote.
public bool CanEnterExamGrades =>
SelectedPeriod.Period == ParticipationPeriod.FullYear && _gradeLevel == ExamGradeLevel;
public bool HasWorks => Works.Count > 0;
public bool HasParticipationGrades => ParticipationGrades.Count > 0;
public bool HasOtherGrades => OtherGrades.Count > 0;
public ReportGradeAssistantViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
IParticipationSessionRepository participationSessions, IParticipationRepository participation,
IParticipationAspectRepository aspects, IParticipationSectionRepository sections,
IInterimGradeRepository interimGrades,
AttendanceBalanceService attendanceBalance, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear,
int gradeLevel, DateOnly? today = null, InterimRunOptions? interim = null)
{
_reportGrades = reportGrades; _participation = participation;
_interimGrades = interimGrades; _interim = interim;
_attendanceBalance = attendanceBalance; _grading = grading;
_groupId = groupId; _gradeLevel = gradeLevel;
_today = today ?? DateOnly.FromDateTime(DateTime.Today);
GroupLabel = groupLabel;
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
_allStudents = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
_sections = sections.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
_timeline = new ParticipationTimelineBuilder(participationSessions, participation, aspects, exams, results,
grades, _membershipsByStudent, grading, groupId, gradingSystem);
_calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
_membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear);
_interimCalculator = new InterimGradeCalculator(_calculator, _timeline, _sections);
// Vorbelegung: das Halbjahr, in dem wir uns gerade befinden — bzw. das des Stichtags.
var half = _interimCalculator.HalfOf(_interim?.Date ?? _today);
_selectedPeriod = PeriodOptions.First(p => p.Period == half);
Reload();
}
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value)
{
OnPropertyChanged(nameof(CanShowPreviousHalf));
OnPropertyChanged(nameof(CanEnterExamGrades));
Reload();
}
partial void OnRoundingRuleChanged(RoundingRule value) => Reload();
partial void OnShowPreviousHalfChanged(bool value) => ShowStudent();
partial void OnSelectedStudentChanged(AssistantStudentItem? value)
{
OnPropertyChanged(nameof(HasStudent));
OnPropertyChanged(nameof(StudentName));
OnPropertyChanged(nameof(ProgressText));
PreviousStudentCommand.NotifyCanExecuteChanged();
NextStudentCommand.NotifyCanExecuteChanged();
if (!_loading) ShowStudent();
}
partial void OnIsSectionModeChanged(bool value)
{
OnPropertyChanged(nameof(IsContinuousMode));
ApplyChartMode();
}
partial void OnIsLockedChanged(bool value) => OnPropertyChanged(nameof(LockLabel));
partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay));
partial void OnCalculatedDisplayChanged(string value) => OnPropertyChanged(nameof(FinalDisplay));
// Eine eingetippte Prüfungsnote wirkt sofort auf die angezeigte Berechnung, gespeichert wird
// sie erst mit der Zeugnisnote.
partial void OnProjectExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
partial void OnOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
partial void OnWrittenExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
partial void OnAdditionalOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
private void ClearErrors()
{
OverrideReasonError = ""; ProjectExamGradeError = ""; OralExamGradeError = "";
WrittenExamGradeError = ""; AdditionalOralExamGradeError = "";
InterimWrittenError = ""; InterimOralError = ""; InterimOverallError = "";
}
// ── Laden ────────────────────────────────────────────────────────────────
private void Reload()
{
_calculator.Reload();
SchemeSummary = _calculator.SchemeSummary;
var (from, to) = _calculator.Range(SelectedPeriod.Period);
var previousId = SelectedStudent?.StudentId;
_loading = true;
Students.Clear();
foreach (var student in _allStudents)
{
if (_interim is not null && !_interim.StudentIds.Contains(student.Id)) continue;
if (_membershipsByStudent.TryGetValue(student.Id, out var membership)
&& !GroupMembershipService.Overlaps(membership, from, to)) continue;
var item = new AssistantStudentItem(student.Id, student.FullName);
RefreshItem(item);
Students.Add(item);
}
SelectedStudent = Students.FirstOrDefault(s => s.StudentId == previousId) ?? Students.FirstOrDefault();
_loading = false;
OnPropertyChanged(nameof(ProgressText));
PreviousStudentCommand.NotifyCanExecuteChanged();
NextStudentCommand.NotifyCanExecuteChanged();
ShowStudent();
}
private void RefreshItem(AssistantStudentItem item)
{
if (_interim is not null) { RefreshInterimItem(item); return; }
var record = _reportGrades.GetByStudentGroupPeriod(item.StudentId, _groupId, SelectedPeriod.Label);
var calculated = record is { IsLocked: true }
? NullIfEmpty(record.CalculatedValue)
: _calculator.Calculate(item.StudentId, SelectedPeriod.Period, RoundingRule).Rounded;
item.Update(NullIfEmpty(record?.OverrideValue) ?? calculated ?? "–", record switch
{
null => AssistantGradeStatus.Open,
{ IsLocked: true } => AssistantGradeStatus.Locked,
_ => AssistantGradeStatus.Saved,
});
}
// ── Berechnung ───────────────────────────────────────────────────────────
// ── Anzeige des gewählten Schülers ───────────────────────────────────────
private void ShowStudent()
{
StatusMessage = "";
ClearErrors();
Works.Clear(); ParticipationGrades.Clear(); OtherGrades.Clear();
if (SelectedStudent is null)
{
_calculatedValue = null;
Tiles.Clear();
CalculatedDisplay = "–"; CalculationDetail = ""; BorderlineHint = ""; AbsenceRateDisplay = "";
OverrideValue = null; OverrideReason = null; IsLocked = false;
InterimEntries.Clear();
OnPropertyChanged(nameof(HasInterimEntries));
_sectionViews = [];
ApplyChartMode();
NotifyLists();
return;
}
var studentId = SelectedStudent.StudentId;
var (from, to) = _calculator.Range(SelectedPeriod.Period);
// Zwischenstand: alles nach dem Stichtag bleibt außen vor, auch in der Anzeige.
if (_interim is not null && _interim.Date < to) to = _interim.Date;
_membershipsByStudent.TryGetValue(studentId, out var membership);
var record = _interim is null
? _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label) : null;
_showing = true;
IsLocked = record is { IsLocked: true };
OverrideValue = record?.OverrideValue;
OverrideReason = record?.OverrideReason;
ProjectExamGrade = CanEnterExamGrades ? record?.ProjectExamGrade : null;
OralExamGrade = CanEnterExamGrades ? record?.OralExamGrade : null;
WrittenExamGrade = CanEnterExamGrades ? record?.WrittenExamGrade : null;
AdditionalOralExamGrade = CanEnterExamGrades ? record?.AdditionalOralExamGrade : null;
_showing = false;
if (_interim is not null) ShowInterim(studentId); else ShowCalculation();
LoadInterimEntries(studentId);
ShowAbsenceRate(studentId, from, to);
// Im 2. Halbjahr auf Wunsch das Vorhalbjahr als Kontext mitzeigen — ohne Einfluss auf die Note.
var displayFrom = CanShowPreviousHalf && ShowPreviousHalf
? _calculator.Range(ParticipationPeriod.FullYear).From : from;
BuildWorks(studentId, membership, displayFrom, from, to);
BuildGradeRows(studentId, membership, displayFrom, from, to);
BuildSections(studentId, displayFrom, to);
NotifyLists();
}
/// Berechnete Note und Teilnoten-Kacheln des gewählten Schülers.
private void ShowCalculation()
{
if (SelectedStudent is null) return;
var studentId = SelectedStudent.StudentId;
var period = SelectedPeriod.Period;
// In Klasse 10 gelten die gerade eingetragenen Prüfungsnoten, sonst die gespeicherten.
YearExamGrades? typed = CanEnterExamGrades
? new YearExamGrades(ProjectExamGrade, OralExamGrade, WrittenExamGrade, AdditionalOralExamGrade)
: null;
var result = _calculator.Calculate(studentId, period, RoundingRule, typed);
// Festgeschrieben heißt: der gespeicherte Stand gilt, es wird nicht neu berechnet.
_calculatedValue = IsLocked
? NullIfEmpty(_reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label)?.CalculatedValue)
: result.Rounded;
CalculatedDisplay = _calculatedValue ?? "–";
// Genau auf der Grenze entscheidet die gewählte Rundung nur formal — der Hinweis macht
// sichtbar, dass hier die Lehrkraft entscheiden sollte.
var between = !IsLocked && result.Value is { } value ? ReportGradeCalculator.BetweenGrades(value) : null;
BorderlineHint = between is null ? "" : $"Liegt genau {between} — pädagogische Entscheidung.";
Tiles.Clear();
if (period == ParticipationPeriod.FullYear)
{
var parts = _calculator.YearParts(studentId, RoundingRule, typed);
foreach (var part in parts)
Tiles.Add(new AssistantTile(part.Label, part.Final ?? "–", part.Hint));
CalculationDetail = result.Value is null
? "Noch keine Halbjahresnote vorhanden."
: $"aus {parts.Count(p => p.Final is not null)} Teilnoten {Format(result.Value.Value)} vor Rundung";
}
else
{
var scheme = _calculator.Scheme;
Tiles.Add(BucketTile("Schriftlich", result.Written, scheme.ExamsPercent));
Tiles.Add(BucketTile("Mitarbeit", result.Participation, scheme.ParticipationPercent));
Tiles.Add(BucketTile("Sonstige", result.Other, scheme.OtherPercent));
CalculationDetail = result.Value is null
? "Noch keine Leistungen im gewählten Zeitraum."
: $"gewichtet {Format(result.Value.Value)} vor Rundung";
}
}
private void NotifyLists()
{
OnPropertyChanged(nameof(HasWorks));
OnPropertyChanged(nameof(HasParticipationGrades));
OnPropertyChanged(nameof(HasOtherGrades));
}
private static AssistantTile BucketTile(string label, double? average, double percent) =>
new(label, average.HasValue ? Format(average.Value) : "–", $"{percent:0.#} %");
private void ShowAbsenceRate(Guid studentId, DateOnly from, DateOnly to)
{
// Wie im Zeugnisnoten-Dialog (2.4.5): nur Sitzungen dieser Gruppe im gewählten Zeitraum.
var attendance = _timeline.Sessions
.Where(s => s.Date >= from && s.Date <= to)
.Select(s => (s.Date, _participation.GetBySessionAndStudent(s.Id, studentId)?.Attendance))
.ToList();
var rate = _attendanceBalance.Calculate(attendance, from, to).AbsenceRatePercent;
AbsenceRateDisplay = $"{rate:0.#} % gefehlt";
AbsenceRateColorHex = rate >= ReportGradeRow.HighAbsenceThresholdPercent ? "#D64545" : "#8A8A8A";
}
private void BuildWorks(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to)
{
foreach (var exam in _calculator.Exams.Where(e => e.Date >= displayFrom && e.Date <= to))
{
if (!ReportGradeCalculator.ExamApplies(exam, membership)) continue;
var results = _calculator.ResultsByExam[exam.Id];
results.TryGetValue(studentId, out var own);
// Nachschreib-, Förder- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes
// Ergebnis gehören sie nicht in diese Übersicht.
if (own is null && exam.RepeatOfExamId.HasValue) continue;
var gradeDisplay = own switch
{
null => "–",
{ Absent: true } => "abw.",
_ => own.Grade ?? "–",
};
var percentDisplay = "";
if (own is { Absent: false })
{
var maximum = ExamScoringService.Maximum(exam.Tasks, own.Variant);
if (maximum > 0) percentDisplay = $"{own.TotalPoints / maximum * 100:0} %";
}
var classGrades = results.Values.Where(r => !r.Absent && r.Grade is not null)
.Select(r => (r.Grade!, 1.0)).ToList();
var classAverage = classGrades.Count > 0 ? $"Ø Kurs {Format(_grading.WeightedAverage(classGrades))}" : "";
var kind = exam.Kind.Label();
if (exam.IsSupportWork) kind += " · Förderarbeit";
else if (exam.IsMakeUp) kind += " · Nachschreibarbeit";
else if (exam.RepeatOfExamId.HasValue) kind += " · Wiederholung";
Works.Add(new AssistantWorkCard(exam.Title, exam.Date, kind, gradeDisplay, percentDisplay,
classAverage, isContext: exam.Date < from));
}
}
private void BuildGradeRows(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to)
{
foreach (var grade in _calculator.Grades
.Where(g => g.StudentId == studentId && g.Date >= displayFrom && g.Date <= to)
.Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
{
var isParticipation = grade.Category == GradeCategory.Participation;
var category = GradeCategoryDisplay.Label(grade.Category);
var label = string.IsNullOrWhiteSpace(grade.Note)
? category
: isParticipation ? grade.Note.Trim() : $"{category}: {grade.Note.Trim()}";
var row = new AssistantGradeRow(label, grade.Date, grade.Value, isContext: grade.Date < from);
(isParticipation ? ParticipationGrades : OtherGrades).Add(row);
}
}
// ── Mitarbeitsgraph: Abschnitte / Verlauf ────────────────────────────────
private sealed record SectionView(WizardSectionGroup Group, string Title);
private void BuildSections(Guid studentId, DateOnly displayFrom, DateOnly to)
{
var points = _timeline.BuildPoints(studentId).Where(p => p.Date >= displayFrom && p.Date <= to).ToList();
// Notierte Zwischenstände erscheinen als Marker im Verlauf.
points.AddRange(_interimGrades.GetByStudentAndGroup(studentId, _groupId)
.Where(g => g.Date >= displayFrom && g.Date <= to)
.Select(g => new WizardTimelinePoint(g.Date, isExam: true, ratingLabel: "",
examLabel: $"Zwischenstand: {g.OverallValue}", hasNote: false,
tooltip: $"{g.Date:dd.MM.yyyy} · {g.Occasion} · Zwischenstand {g.OverallValue}")));
points = points.OrderBy(p => p.Date).ToList();
var sectionGrades = _calculator.Grades
.Where(g => g.StudentId == studentId && g.Category == GradeCategory.Participation && g.Note is not null)
.ToList();
var views = new List<SectionView>();
foreach (var section in _sections.Where(s => s.EndDate >= displayFrom && s.StartDate <= to))
{
var group = new WizardSectionGroup(ParticipationTimelineBuilder.BandLabel(section), isOpen: false);
foreach (var point in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
group.Points.Add(point);
var grade = sectionGrades.FirstOrDefault(g =>
g.Note == ParticipationTimelineBuilder.SectionNotePrefix + section.Label)?.Value;
views.Add(new SectionView(group,
$"{section.Label} · {section.StartDate:dd.MM.}–{section.EndDate:dd.MM.} · " +
(grade is null ? "keine Abschnittsnote" : $"Abschnittsnote {grade}")));
}
// Der laufende Abschnitt endet erst mit dem nächsten Abschluss im Mitarbeits-Assistenten;
// bis dahin gibt es nur einen Vorschlag aus den bisherigen Sitzungen.
var asOf = _interim?.Date ?? _today;
// Ohne jeden abgeschlossenen Abschnitt gehört der ganze Zeitraum zum laufenden — sonst
// fielen Arbeiten und Zwischenstände vor der ersten Mitarbeit-Sitzung aus dem Verlauf.
var openStart = _sections.Count == 0 ? displayFrom : _timeline.OpenStart(_sections, asOf);
if (openStart <= to)
{
var group = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true);
foreach (var point in points.Where(p => p.Date >= openStart))
group.Points.Add(point);
var end = asOf < to ? asOf : to;
var suggestion = _timeline.ComputeSuggestion(studentId, openStart < displayFrom ? displayFrom : openStart, end);
views.Add(new SectionView(group,
$"Laufender Abschnitt seit {openStart:dd.MM.} · " +
(suggestion is null ? "noch kein Vorschlag" : $"vorläufig {suggestion}")));
}
_sectionViews = views;
// Beim Schülerwechsel im selben Abschnitt bleiben — so lassen sich Schüler abschnittsweise vergleichen.
_sectionIndex = Math.Clamp(_sectionIndex, 0, Math.Max(0, views.Count - 1));
ApplyChartMode();
}
private void ApplyChartMode()
{
if (_sectionViews.Count == 0)
{
ChartGroups = [];
SectionTitle = ""; SectionPosition = "";
}
else if (IsSectionMode)
{
var view = _sectionViews[_sectionIndex];
ChartGroups = [view.Group];
SectionTitle = view.Title;
SectionPosition = $"{_sectionIndex + 1} / {_sectionViews.Count}";
}
else
{
ChartGroups = _sectionViews.Select(v => v.Group).ToList();
SectionTitle = ""; SectionPosition = "";
}
PreviousSectionCommand.NotifyCanExecuteChanged();
NextSectionCommand.NotifyCanExecuteChanged();
}
private bool CanGoPreviousSection => IsSectionMode && _sectionIndex > 0;
private bool CanGoNextSection => IsSectionMode && _sectionIndex < _sectionViews.Count - 1;
[RelayCommand(CanExecute = nameof(CanGoPreviousSection))]
private void PreviousSection() { _sectionIndex--; ApplyChartMode(); }
[RelayCommand(CanExecute = nameof(CanGoNextSection))]
private void NextSection() { _sectionIndex++; ApplyChartMode(); }
// ── Navigation ───────────────────────────────────────────────────────────
private int StudentIndex => SelectedStudent is null ? -1 : Students.IndexOf(SelectedStudent);
private bool CanGoPreviousStudent => StudentIndex > 0;
private bool CanGoNextStudent => StudentIndex >= 0 && StudentIndex < Students.Count - 1;
[RelayCommand(CanExecute = nameof(CanGoPreviousStudent))]
private void PreviousStudent() => SelectedStudent = Students[StudentIndex - 1];
[RelayCommand(CanExecute = nameof(CanGoNextStudent))]
private void NextStudent() => SelectedStudent = Students[StudentIndex + 1];
// ── Speichern / Festschreiben ────────────────────────────────────────────
[RelayCommand]
private void Save() => TrySave();
[RelayCommand]
private void SaveAndNext()
{
if (!TrySave()) return;
if (CanGoNextStudent) NextStudent();
}
private bool TrySave()
{
if (SelectedStudent is null || IsLocked) return false;
ClearErrors();
var valid = true;
if (!string.IsNullOrWhiteSpace(OverrideValue) && string.IsNullOrWhiteSpace(OverrideReason))
{
OverrideReasonError = "Für ein manuelles Übersteuern ist eine Begründung Pflicht.";
valid = false;
}
if (CanEnterExamGrades)
{
if (!IsGradeOrEmpty(ProjectExamGrade)) { ProjectExamGradeError = "Keine gültige Note."; valid = false; }
if (!IsGradeOrEmpty(OralExamGrade)) { OralExamGradeError = "Keine gültige Note."; valid = false; }
if (!IsGradeOrEmpty(WrittenExamGrade)) { WrittenExamGradeError = "Keine gültige Note."; valid = false; }
if (!IsGradeOrEmpty(AdditionalOralExamGrade)) { AdditionalOralExamGradeError = "Keine gültige Note."; valid = false; }
// Ein Fach ist entweder schriftliches oder mündliches Prüfungsfach; eine zusätzliche
// mündliche Prüfung gibt es nur im schriftlichen Prüfungsfach oder ganz ohne Abschlussprüfung.
var hasOral = NullIfEmpty(OralExamGrade) is not null;
if (hasOral && NullIfEmpty(WrittenExamGrade) is not null)
{
OralExamGradeError = "Entweder schriftliche oder mündliche Abschlussprüfung.";
valid = false;
}
if (hasOral && NullIfEmpty(AdditionalOralExamGrade) is not null)
{
AdditionalOralExamGradeError = "Im Fach der mündlichen Abschlussprüfung nicht vorgesehen.";
valid = false;
}
}
if (!valid) return false;
var record = _reportGrades.GetByStudentGroupPeriod(SelectedStudent.StudentId, _groupId, SelectedPeriod.Label)
?? new ReportGrade { StudentId = SelectedStudent.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label };
record.CalculatedValue = _calculatedValue ?? "";
record.OverrideValue = NullIfEmpty(OverrideValue);
record.OverrideReason = NullIfEmpty(OverrideReason);
if (CanEnterExamGrades)
{
record.ProjectExamGrade = NullIfEmpty(ProjectExamGrade);
record.OralExamGrade = NullIfEmpty(OralExamGrade);
record.WrittenExamGrade = NullIfEmpty(WrittenExamGrade);
record.AdditionalOralExamGrade = NullIfEmpty(AdditionalOralExamGrade);
}
record.UpdatedAt = DateTime.UtcNow;
_reportGrades.Save(record);
RefreshItem(SelectedStudent);
StatusMessage = "Gespeichert.";
return true;
}
[RelayCommand]
private void ToggleLock()
{
if (SelectedStudent is null) return;
var studentId = SelectedStudent.StudentId;
var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
if (!IsLocked)
{
// Festschreiben sichert immer zuerst den aktuell angezeigten Stand.
if (!TrySave()) return;
record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
}
if (record is null) return;
record.IsLocked = !record.IsLocked;
record.UpdatedAt = DateTime.UtcNow;
_reportGrades.Save(record);
RefreshItem(SelectedStudent);
ShowStudent();
StatusMessage = record.IsLocked ? "Festgeschrieben." : "Entsperrt.";
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// Ganze Note bzw. Punktzahl, optional mit Tendenz ("3+", "2-") — dieselbe Schreibweise, die
/// die Notenberechnung versteht; alles andere würde dort stillschweigend ausgelassen.
private static bool IsGradeOrEmpty(string? value)
{
var text = NullIfEmpty(value);
return text is null || int.TryParse(text.TrimEnd('+', '-').TrimEnd(), out _);
}
private static string Format(double value) => value.ToString("0.00", German);
}
// ── Bausteine ────────────────────────────────────────────────────────────────
public enum AssistantGradeStatus { Open, Saved, Locked, Noted }
public partial class AssistantStudentItem(Guid studentId, string name) : ObservableObject
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
[ObservableProperty] private string _gradeDisplay = "–";
[ObservableProperty] private AssistantGradeStatus _status;
public string StatusLabel => Status switch
{
AssistantGradeStatus.Locked => "festgeschrieben",
AssistantGradeStatus.Saved => "gespeichert",
AssistantGradeStatus.Noted => "notiert",
_ => "offen",
};
public string StatusColorHex => Status switch
{
AssistantGradeStatus.Locked => "#2E9D57",
AssistantGradeStatus.Saved or AssistantGradeStatus.Noted => "#2E86DE",
_ => "#8A8A8A",
};
public void Update(string gradeDisplay, AssistantGradeStatus status)
{
GradeDisplay = gradeDisplay;
Status = status;
}
partial void OnStatusChanged(AssistantGradeStatus value)
{
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(StatusColorHex));
}
}
public class AssistantTile(string label, string value, string hint)
{
public string Label { get; } = label;
public string Value { get; } = value;
public string Hint { get; } = hint;
}
public class AssistantWorkCard(string title, DateOnly date, string kindLabel, string gradeDisplay,
string percentDisplay, string classAverageDisplay, bool isContext)
{
public string Title { get; } = title;
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
public string KindLabel { get; } = kindLabel;
public string GradeDisplay { get; } = gradeDisplay;
public string PercentDisplay { get; } = percentDisplay;
public string ClassAverageDisplay { get; } = classAverageDisplay;
/// Vorhalbjahr: nur zum Vergleich gezeigt, zählt nicht in die aktuelle Note.
public bool IsContext { get; } = isContext;
public double DisplayOpacity => IsContext ? 0.45 : 1.0;
public string ContextLabel => IsContext ? "1. Halbjahr · zählt nicht" : "";
}
public class AssistantGradeRow(string label, DateOnly date, string value, bool isContext)
{
public string Label { get; } = label;
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
public string Value { get; } = value;
public bool IsContext { get; } = isContext;
public double DisplayOpacity => IsContext ? 0.45 : 1.0;
}