CI / build-and-test (push) Canceled after 0s
Ein Zwischenstand ist ein Durchgang aus Stichtag, Anlass und Schülerauswahl (ganze Gruppe, Teilgruppe oder Einzelfall, mit Schnellfilter für gefährdete Schüler). Der Zeugnisnoten-Assistent geht die gewählten Schüler im Zwischenstand-Modus durch und notiert vorläufige schriftliche, mündliche und Gesamtnote samt Notiz. Gespeichert wird das als eigenes Modell InterimGrade, getrennt von der Zeugnisnote. Die Berechnung endet am Stichtag und rechnet den laufenden Mitarbeits-Abschnitt mit ein. Zwischenstände erscheinen im Assistenten als Liste und Marker im Verlauf sowie als graue Spalte in der Notenübersicht (nicht in "Gesamt", nicht im PDF). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
9.7 KiB
C#
212 lines
9.7 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;
|
||
|
||
// ── Zwischenstand starten (2.7.5) ────────────────────────────────────────────
|
||
//
|
||
// Legt einmal Stichtag, Anlass und Schülerauswahl fest; danach geht der Zeugnisnoten-Assistent
|
||
// im Zwischenstand-Modus nur die gewählten Schüler durch. Ganze Gruppe (Sprechtag, blaue
|
||
// Briefe), Teilgruppe (Konferenz) und Einzelfall (Elternanfrage, Abgang) sind derselbe Ablauf
|
||
// mit unterschiedlicher Auswahl.
|
||
public partial class InterimRunDialogViewModel : ObservableObject
|
||
{
|
||
private const string DateFormat = "dd.MM.yyyy";
|
||
private const string OtherOccasion = "Sonstiges";
|
||
|
||
private readonly IInterimGradeRepository _interimGrades;
|
||
private readonly InterimGradeCalculator _calculator;
|
||
private readonly ReportGradeCalculator _reportCalculator;
|
||
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
|
||
private readonly List<Student> _allStudents;
|
||
private readonly Guid _groupId;
|
||
private readonly GradingSystem _gradingSystem;
|
||
private readonly Guid? _preselectedStudentId;
|
||
|
||
public string GroupLabel { get; }
|
||
|
||
[ObservableProperty] private string _dateText;
|
||
[ObservableProperty] private string _dateTextError = "";
|
||
[ObservableProperty] private string _occasionName;
|
||
[ObservableProperty] private string _occasionDetail = "";
|
||
[ObservableProperty] private string _thresholdText;
|
||
[ObservableProperty] private string _thresholdTextError = "";
|
||
[ObservableProperty] private string _selectionError = "";
|
||
[ObservableProperty] private InterimRunChoice? _selectedExistingRun;
|
||
|
||
public string[] OccasionOptions { get; } =
|
||
["Sprechtag", "Blauer Brief", "Konferenz", "Elternanfrage", "Schülerrückfrage", "Abgang", OtherOccasion];
|
||
|
||
public ObservableCollection<InterimRunStudentRow> Students { get; } = [];
|
||
public List<InterimRunChoice> ExistingRuns { get; }
|
||
public bool HasExistingRuns => ExistingRuns.Count > 0;
|
||
public string AtRiskLabel => _gradingSystem == GradingSystem.Grades1To6
|
||
? "Nur gefährdete: Note ab" : "Nur gefährdete: Punkte bis";
|
||
|
||
/// Nach erfolgreichem <see cref="Confirm"/> gesetzt.
|
||
public InterimRunOptions? Result { get; private set; }
|
||
|
||
public InterimRunDialogViewModel(IGradeRepository grades, IExamRepository exams,
|
||
IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
|
||
IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
|
||
IParticipationSessionRepository participationSessions, IParticipationRepository participation,
|
||
IParticipationAspectRepository aspects, IParticipationSectionRepository sections,
|
||
IInterimGradeRepository interimGrades, GradingService grading,
|
||
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear,
|
||
Guid? preselectedStudentId = null, DateOnly? today = null)
|
||
{
|
||
_interimGrades = interimGrades; _groupId = groupId; _gradingSystem = gradingSystem;
|
||
_preselectedStudentId = preselectedStudentId;
|
||
GroupLabel = groupLabel;
|
||
|
||
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
|
||
_allStudents = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
|
||
var timeline = new ParticipationTimelineBuilder(participationSessions, participation, aspects, exams, results,
|
||
grades, _membershipsByStudent, grading, groupId, gradingSystem);
|
||
_reportCalculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
|
||
_membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear);
|
||
_calculator = new InterimGradeCalculator(_reportCalculator, timeline,
|
||
sections.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList());
|
||
|
||
ExistingRuns = interimGrades.GetByGroup(groupId)
|
||
.GroupBy(g => (g.Date, g.Occasion))
|
||
.OrderByDescending(g => g.Key.Date)
|
||
.Select(g => new InterimRunChoice(g.Key.Date, g.Key.Occasion, g.Count()))
|
||
.ToList();
|
||
|
||
_occasionName = OccasionOptions[0];
|
||
_thresholdText = gradingSystem == GradingSystem.Grades1To6 ? "5" : "4";
|
||
_dateText = (today ?? DateOnly.FromDateTime(DateTime.Today)).ToString(DateFormat);
|
||
RebuildStudents();
|
||
}
|
||
|
||
public string Occasion
|
||
{
|
||
get
|
||
{
|
||
var detail = OccasionDetail.Trim();
|
||
if (detail.Length == 0) return OccasionName;
|
||
return OccasionName == OtherOccasion ? detail : $"{OccasionName}: {detail}";
|
||
}
|
||
}
|
||
|
||
partial void OnDateTextChanged(string value) => RebuildStudents();
|
||
partial void OnOccasionNameChanged(string value) => RefreshNotedMarkers();
|
||
partial void OnOccasionDetailChanged(string value) => RefreshNotedMarkers();
|
||
|
||
partial void OnSelectedExistingRunChanged(InterimRunChoice? value)
|
||
{
|
||
if (value is null) return;
|
||
// Anlass zurück in Auswahl + Ergänzung zerlegen, damit derselbe Durchgang fortgesetzt wird.
|
||
var option = OccasionOptions.FirstOrDefault(o => o != OtherOccasion
|
||
&& (value.Occasion == o || value.Occasion.StartsWith(o + ": ", StringComparison.Ordinal)));
|
||
OccasionName = option ?? OtherOccasion;
|
||
OccasionDetail = option is null ? value.Occasion
|
||
: value.Occasion.Length > option.Length ? value.Occasion[(option.Length + 2)..] : "";
|
||
DateText = value.Date.ToString(DateFormat);
|
||
}
|
||
|
||
private bool TryParseDate(out DateOnly date)
|
||
{
|
||
DateTextError = "";
|
||
if (!DateOnly.TryParseExact(DateText.Trim(), DateFormat, null, DateTimeStyles.None, out date))
|
||
{
|
||
DateTextError = "Format TT.MM.JJJJ.";
|
||
return false;
|
||
}
|
||
var (from, to) = _reportCalculator.Range(ParticipationPeriod.FullYear);
|
||
if (date < from || date > to)
|
||
{
|
||
DateTextError = "Stichtag liegt außerhalb des Schuljahrs.";
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private void RebuildStudents()
|
||
{
|
||
// Auswahl über den Neuaufbau retten; beim ersten Aufbau gilt die Vorauswahl.
|
||
var previous = Students.ToDictionary(s => s.StudentId, s => s.IsSelected);
|
||
Students.Clear();
|
||
if (!TryParseDate(out var date)) return;
|
||
|
||
foreach (var student in _allStudents)
|
||
{
|
||
if (_membershipsByStudent.TryGetValue(student.Id, out var membership)
|
||
&& !GroupMembershipService.IsActiveOn(membership, date)) continue;
|
||
var result = _calculator.Calculate(student.Id, date, RoundingRule.Commercial).Result;
|
||
var selected = previous.TryGetValue(student.Id, out var was)
|
||
? was
|
||
: _preselectedStudentId is null || _preselectedStudentId == student.Id;
|
||
Students.Add(new InterimRunStudentRow(student.Id, student.FullName, result.Rounded) { IsSelected = selected });
|
||
}
|
||
RefreshNotedMarkers();
|
||
}
|
||
|
||
private void RefreshNotedMarkers()
|
||
{
|
||
if (!DateOnly.TryParseExact(DateText.Trim(), DateFormat, null, DateTimeStyles.None, out var date)) return;
|
||
var noted = _interimGrades.GetByGroup(_groupId)
|
||
.Where(g => g.Date == date && g.Occasion == Occasion)
|
||
.GroupBy(g => g.StudentId).ToDictionary(g => g.Key, g => g.First().OverallValue);
|
||
foreach (var row in Students)
|
||
row.NotedDisplay = noted.TryGetValue(row.StudentId, out var value) ? $"notiert: {value}" : "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void SelectAll() { foreach (var row in Students) row.IsSelected = true; }
|
||
|
||
[RelayCommand]
|
||
private void SelectNone() { foreach (var row in Students) row.IsSelected = false; }
|
||
|
||
/// Wählt genau die Schüler, deren vorläufige Note die Schwelle erreicht — für blaue Briefe.
|
||
[RelayCommand]
|
||
private void SelectAtRisk()
|
||
{
|
||
ThresholdTextError = "";
|
||
if (!int.TryParse(ThresholdText.Trim(), out var threshold))
|
||
{
|
||
ThresholdTextError = "Ganze Zahl erforderlich.";
|
||
return;
|
||
}
|
||
foreach (var row in Students)
|
||
row.IsSelected = int.TryParse(row.ProvisionalGrade, out var grade)
|
||
&& (_gradingSystem == GradingSystem.Grades1To6 ? grade >= threshold : grade <= threshold);
|
||
}
|
||
|
||
public bool Confirm()
|
||
{
|
||
SelectionError = "";
|
||
var valid = TryParseDate(out var date);
|
||
var selected = Students.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
|
||
if (valid && selected.Count == 0) { SelectionError = "Mindestens einen Schüler auswählen."; valid = false; }
|
||
if (!valid) return false;
|
||
|
||
Result = new InterimRunOptions(date, Occasion, selected);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
public class InterimRunChoice(DateOnly date, string occasion, int count)
|
||
{
|
||
public DateOnly Date { get; } = date;
|
||
public string Occasion { get; } = occasion;
|
||
public override string ToString() => $"{Date:dd.MM.yyyy} · {Occasion} ({count} notiert)";
|
||
}
|
||
|
||
public partial class InterimRunStudentRow(Guid studentId, string name, string? provisionalGrade) : ObservableObject
|
||
{
|
||
public Guid StudentId { get; } = studentId;
|
||
public string Name { get; } = name;
|
||
public string? ProvisionalGrade { get; } = provisionalGrade;
|
||
public string ProvisionalDisplay => ProvisionalGrade ?? "–";
|
||
|
||
[ObservableProperty] private bool _isSelected;
|
||
[ObservableProperty] private string _notedDisplay = "";
|
||
}
|