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>
665 lines
31 KiB
C#
665 lines
31 KiB
C#
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 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,
|
||
AttendanceBalanceService attendanceBalance, GradingService grading,
|
||
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear,
|
||
int gradeLevel, DateOnly? today = null)
|
||
{
|
||
_reportGrades = reportGrades; _participation = participation;
|
||
_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);
|
||
|
||
// Vorbelegung: das Halbjahr, in dem wir uns gerade befinden.
|
||
var (h1From, h1To) = _calculator.Range(ParticipationPeriod.H1);
|
||
_selectedPeriod = _today >= h1From && _today <= h1To ? PeriodOptions[0] : PeriodOptions[1];
|
||
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 = "";
|
||
}
|
||
|
||
// ── 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 (_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)
|
||
{
|
||
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;
|
||
_sectionViews = [];
|
||
ApplyChartMode();
|
||
NotifyLists();
|
||
return;
|
||
}
|
||
|
||
var studentId = SelectedStudent.StudentId;
|
||
var (from, to) = _calculator.Range(SelectedPeriod.Period);
|
||
_membershipsByStudent.TryGetValue(studentId, out var membership);
|
||
|
||
var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
|
||
_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;
|
||
|
||
ShowCalculation();
|
||
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- 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.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();
|
||
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 openStart = _timeline.OpenStart(_sections, _today);
|
||
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 = _today < to ? _today : 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 }
|
||
|
||
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",
|
||
_ => "offen",
|
||
};
|
||
|
||
public string StatusColorHex => Status switch
|
||
{
|
||
AssistantGradeStatus.Locked => "#2E9D57",
|
||
AssistantGradeStatus.Saved => "#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;
|
||
}
|