Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModel.Interim.cs
adminandClaude Opus 5.5 d9eac0d1d2
CI / build-and-test (push) Canceled after 0s
Zwischenstand: vorläufige Noten zu Stichtag und Anlass
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>
2026-10-06 13:01:34 +02:00

166 lines
7.6 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.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// Ein Zwischenstand-Durchgang: Stichtag und Anlass gelten für alle gewählten Schüler.
public sealed record InterimRunOptions(DateOnly Date, string Occasion, IReadOnlyCollection<Guid> StudentIds);
// ── Zwischenstand-Modus des Assistenten (2.7.5) ──────────────────────────────
//
// Mit InterimRunOptions gestartet, geht der Assistent nur die gewählten Schüler durch und
// notiert je Schüler eine vorläufige schriftliche, mündliche und Gesamtnote zum Stichtag —
// als InterimGrade, ohne die Zeugnisnote (ReportGrade) anzufassen. Vorgeschlagen wird der
// Stand aus InterimGradeCalculator, also inklusive des laufenden Mitarbeits-Abschnitts.
public partial class ReportGradeAssistantViewModel
{
[ObservableProperty] private string? _interimWritten;
[ObservableProperty] private string? _interimOral;
[ObservableProperty] private string? _interimOverall;
[ObservableProperty] private string? _interimNote;
[ObservableProperty] private string _interimWrittenError = "";
[ObservableProperty] private string _interimOralError = "";
[ObservableProperty] private string _interimOverallError = "";
[ObservableProperty] private bool _hasInterimEntry;
/// Alle bisher notierten Zwischenstände des gewählten Schülers — in beiden Modi sichtbar.
public ObservableCollection<AssistantInterimRow> InterimEntries { get; } = [];
public bool HasInterimEntries => InterimEntries.Count > 0;
public bool IsInterimMode => _interim is not null;
public bool IsReportMode => _interim is null;
public string WindowTitle => IsInterimMode ? "Zwischenstand" : "Zeugnisnoten-Assistent";
public string InterimTitle => _interim is null
? "" : $"Zwischenstand {_interim.Date:dd.MM.yyyy} · {_interim.Occasion}";
/// Startet aus dem Zeugnisnoten-Modus einen Zwischenstand für den gezeigten Schüler; vom
/// Fenster gesetzt, das den Startdialog öffnen kann.
public Func<Guid, Task>? OnStartInterim { get; set; }
private InterimGrade? CurrentInterim(Guid studentId) => _interim is null ? null
: _interimGrades.GetByStudentAndGroup(studentId, _groupId)
.FirstOrDefault(g => g.Date == _interim.Date && g.Occasion == _interim.Occasion);
private void RefreshInterimItem(AssistantStudentItem item)
{
var existing = CurrentInterim(item.StudentId);
if (existing is not null)
item.Update(existing.OverallValue, AssistantGradeStatus.Noted);
else
item.Update(_interimCalculator.Calculate(item.StudentId, _interim!.Date, RoundingRule).Result.Rounded ?? "–",
AssistantGradeStatus.Open);
}
private void ShowInterim(Guid studentId)
{
var interim = _interimCalculator.Calculate(studentId, _interim!.Date, RoundingRule);
var result = interim.Result;
_calculatedValue = result.Rounded;
CalculatedDisplay = result.Rounded ?? "–";
var between = result.Value is { } value ? ReportGradeCalculator.BetweenGrades(value) : null;
BorderlineHint = between is null ? "" : $"Liegt genau {between} — pädagogische Entscheidung.";
CalculationDetail = result.Value is null
? "Noch keine Leistungen bis zum Stichtag."
: $"Stand {_interim.Date:dd.MM.yyyy} · gewichtet {Format(result.Value.Value)} vor Rundung";
var scheme = _calculator.Scheme;
Tiles.Clear();
Tiles.Add(BucketTile("Schriftlich", result.Written, scheme.ExamsPercent));
Tiles.Add(new AssistantTile("Mitarbeit",
result.Participation.HasValue ? Format(result.Participation.Value) : "–",
$"{scheme.ParticipationPercent:0.#} %" +
(interim.RunningParticipation is null ? "" : $" · laufender Abschnitt: {interim.RunningParticipation}")));
Tiles.Add(BucketTile("Sonstige", result.Other, scheme.OtherPercent));
// Bereits notiert: der notierte Stand gilt. Sonst der berechnete Vorschlag zum Anpassen.
var existing = CurrentInterim(studentId);
HasInterimEntry = existing is not null;
InterimWritten = existing is not null ? existing.WrittenValue : RoundOrNull(result.Written);
InterimOral = existing is not null ? existing.OralValue : RoundOrNull(result.Participation);
InterimOverall = existing is not null ? existing.OverallValue : result.Rounded;
InterimNote = existing?.Note;
}
private string? RoundOrNull(double? value) => value.HasValue ? _calculator.Round(value.Value, RoundingRule) : null;
private void LoadInterimEntries(Guid studentId)
{
InterimEntries.Clear();
foreach (var entry in _interimGrades.GetByStudentAndGroup(studentId, _groupId)
.OrderByDescending(g => g.Date).ThenByDescending(g => g.UpdatedAt))
InterimEntries.Add(new AssistantInterimRow(entry));
OnPropertyChanged(nameof(HasInterimEntries));
}
[RelayCommand]
private void NoteInterim() => TryNoteInterim();
[RelayCommand]
private void NoteInterimAndNext()
{
if (!TryNoteInterim()) return;
if (CanGoNextStudent) NextStudent();
}
private bool TryNoteInterim()
{
if (_interim is null || SelectedStudent is null) return false;
ClearErrors();
var valid = true;
if (!IsGradeOrEmpty(InterimWritten)) { InterimWrittenError = "Keine gültige Note."; valid = false; }
if (!IsGradeOrEmpty(InterimOral)) { InterimOralError = "Keine gültige Note."; valid = false; }
if (NullIfEmpty(InterimOverall) is null) { InterimOverallError = "Gesamtnote erforderlich."; valid = false; }
else if (!IsGradeOrEmpty(InterimOverall)) { InterimOverallError = "Keine gültige Note."; valid = false; }
if (!valid) return false;
var studentId = SelectedStudent.StudentId;
var entry = CurrentInterim(studentId) ?? new InterimGrade
{
StudentId = studentId, GroupId = _groupId, Date = _interim.Date, Occasion = _interim.Occasion,
};
entry.WrittenValue = NullIfEmpty(InterimWritten);
entry.OralValue = NullIfEmpty(InterimOral);
entry.OverallValue = NullIfEmpty(InterimOverall)!;
entry.Note = NullIfEmpty(InterimNote);
_interimGrades.Save(entry);
RefreshItem(SelectedStudent);
ShowStudent();
StatusMessage = "Zwischenstand notiert.";
return true;
}
[RelayCommand]
private void RemoveInterim()
{
if (SelectedStudent is null || CurrentInterim(SelectedStudent.StudentId) is not { } entry) return;
_interimGrades.Delete(entry.Id);
RefreshItem(SelectedStudent);
ShowStudent();
StatusMessage = "Zwischenstand entfernt.";
}
[RelayCommand]
private async Task StartInterim()
{
if (OnStartInterim is null || SelectedStudent is null) return;
await OnStartInterim(SelectedStudent.StudentId);
ShowStudent();
}
}
public class AssistantInterimRow(InterimGrade entry)
{
public string Title { get; } = $"{entry.Date:dd.MM.yyyy} · {entry.Occasion}";
public string OverallValue { get; } = entry.OverallValue;
public string Detail { get; } = string.Join(" · ", new[]
{
entry.WrittenValue is null ? null : $"schriftlich {entry.WrittenValue}",
entry.OralValue is null ? null : $"mündlich {entry.OralValue}",
}.Where(part => part is not null));
public string Note { get; } = entry.Note ?? "";
}