Sidebar-Klausuren war bisher ein Placeholder. Neue Seite zeigt alle Klausuren des Schuljahres sortiert nach abgeleiteter Dringlichkeit (Korrekturfortschritt statt manuellem Status), mit Detailbereich, Parallelkurs-Umschalter und Notenspiegel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
254 lines
10 KiB
C#
254 lines
10 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.ViewModels.Groups;
|
|
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
|
|
namespace LehrerApp.Desktop.ViewModels.Exams;
|
|
|
|
/// Klausuren-Hauptseite (Sidebar "Klausuren"): gruppenübergreifende Liste aller Klausuren des
|
|
/// aktuellen Schuljahres, sortiert nach einem unsichtbaren Prioritäts-Score
|
|
/// (`ExamPriorityService`) statt nach Datum — unbearbeitete/überfällige Korrekturen oben,
|
|
/// erledigte unten. Oben ein Detailbereich zur ausgewählten Klausur, der sich je nach Status
|
|
/// unterscheidet (Korrekturfortschritt vs. Notenspiegel), inklusive Umschalter für Parallelkurse
|
|
/// (gleiche Arbeit, mehrere Kurse — siehe `Exam.SharedExamGroupId`).
|
|
public partial class ExamsOverviewViewModel : ObservableObject
|
|
{
|
|
private readonly IExamRepository _exams;
|
|
private readonly IExamResultRepository _examResults;
|
|
private readonly IGroupRepository _groups;
|
|
private readonly IGroupMembershipRepository _memberships;
|
|
private readonly GradingService _grading;
|
|
private readonly SchoolYearService _schoolYear;
|
|
|
|
public ObservableCollection<ExamListRowViewModel> Rows { get; } = [];
|
|
public ObservableCollection<ExamListRowViewModel> Siblings { get; } = [];
|
|
public ObservableCollection<GradeBarItem> DetailGradeDistribution { get; } = [];
|
|
|
|
[ObservableProperty] private ExamListRowViewModel? _selectedRow;
|
|
[ObservableProperty] private bool _hasSelection;
|
|
[ObservableProperty] private string _emptyHint = "";
|
|
|
|
[ObservableProperty] private bool _showCorrectionProgress;
|
|
[ObservableProperty] private string _progressLabel = "";
|
|
[ObservableProperty] private double _progressBarWidth;
|
|
[ObservableProperty] private bool _showGradeSummary;
|
|
[ObservableProperty] private string _averageLabel = "";
|
|
[ObservableProperty] private string _approvalLabel = "";
|
|
[ObservableProperty] private string _announcementLabel = "";
|
|
|
|
public Func<Exam, Task>? OnGradeExam { get; set; }
|
|
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
|
public Action<Guid>? OnNavigateToGroup { get; set; }
|
|
|
|
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
|
|
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
|
|
SchoolYearService schoolYear)
|
|
{
|
|
_exams = exams; _examResults = examResults; _groups = groups;
|
|
_memberships = memberships; _grading = grading; _schoolYear = schoolYear;
|
|
}
|
|
|
|
[RelayCommand]
|
|
public void Load()
|
|
{
|
|
var selectedId = SelectedRow?.Exam.Id;
|
|
Rows.Clear();
|
|
|
|
var groups = _groups.GetBySchoolYear(_schoolYear.CurrentSchoolYear());
|
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
|
|
|
var rows = new List<ExamListRowViewModel>();
|
|
foreach (var group in groups)
|
|
{
|
|
var groupMemberships = _memberships.GetByGroup(group.Id);
|
|
foreach (var exam in _exams.GetByGroup(group.Id))
|
|
{
|
|
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, groupMemberships,
|
|
_examResults.GetByExam(exam.Id));
|
|
var info = ExamPriorityService.Evaluate(exam, expected, evaluated, today);
|
|
rows.Add(new ExamListRowViewModel(exam, group, expected, evaluated, info, today));
|
|
}
|
|
}
|
|
|
|
// Parallelkurse markieren, damit die Liste einen Verknüpfungs-Hinweis zeigen kann.
|
|
foreach (var group in rows.GroupBy(r => r.Exam.SharedExamGroupId ?? r.Exam.Id))
|
|
{
|
|
if (group.Count() <= 1) continue;
|
|
foreach (var row in group) row.HasSibling = true;
|
|
}
|
|
|
|
foreach (var row in rows.OrderByDescending(r => r.Score))
|
|
Rows.Add(row);
|
|
|
|
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
|
|
SelectedRow = selectedId is { } id
|
|
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
|
|
: Rows.FirstOrDefault();
|
|
}
|
|
|
|
partial void OnSelectedRowChanged(ExamListRowViewModel? value) => UpdateDetail(value);
|
|
|
|
private void UpdateDetail(ExamListRowViewModel? row)
|
|
{
|
|
HasSelection = row is not null;
|
|
Siblings.Clear();
|
|
DetailGradeDistribution.Clear();
|
|
ShowCorrectionProgress = false;
|
|
ShowGradeSummary = false;
|
|
if (row is null) return;
|
|
|
|
ApprovalLabel = row.Exam.ApprovalGrantedAt is { } a
|
|
? $"Genehmigt am {a.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
|
|
: "Genehmigung noch ausstehend";
|
|
AnnouncementLabel = row.Exam.AnnouncedAt is { } n
|
|
? $"Angekündigt am {n.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
|
|
: "Noch nicht angekündigt";
|
|
|
|
if (row.HasSibling)
|
|
{
|
|
var anchor = row.Exam.SharedExamGroupId ?? row.Exam.Id;
|
|
foreach (var sibling in Rows.Where(r => r != row && (r.Exam.SharedExamGroupId ?? r.Exam.Id) == anchor))
|
|
Siblings.Add(sibling);
|
|
}
|
|
|
|
ShowCorrectionProgress = row.Status is ExamListStatus.AwaitingCorrection
|
|
or ExamListStatus.CorrectionInProgress or ExamListStatus.CorrectionStuck;
|
|
if (ShowCorrectionProgress)
|
|
{
|
|
ProgressLabel = $"{row.Evaluated} / {row.Expected} korrigiert";
|
|
var fraction = row.Expected <= 0 ? 0 : Math.Clamp((double)row.Evaluated / row.Expected, 0, 1);
|
|
ProgressBarWidth = fraction * 240.0;
|
|
}
|
|
|
|
ShowGradeSummary = row.Status is ExamListStatus.AwaitingReturn or ExamListStatus.Returned;
|
|
if (ShowGradeSummary) LoadGradeSummary(row);
|
|
}
|
|
|
|
private void LoadGradeSummary(ExamListRowViewModel row)
|
|
{
|
|
var grades = _examResults.GetByExam(row.Exam.Id)
|
|
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
|
.Select(r => r.Grade!).ToList();
|
|
if (grades.Count == 0) { AverageLabel = "Noch keine Noten erfasst."; return; }
|
|
|
|
AverageLabel = "Ø " + _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList())
|
|
.ToString("0.00", CultureInfo.InvariantCulture);
|
|
|
|
var counts = grades.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count());
|
|
var maxCount = counts.Count == 0 ? 0 : counts.Values.Max();
|
|
foreach (var entry in row.Exam.GradingKey.OrderByDescending(e => e.MinPercent))
|
|
{
|
|
counts.TryGetValue(entry.Grade, out var count);
|
|
DetailGradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount));
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectExam(ExamListRowViewModel row) => SelectedRow = row;
|
|
|
|
[RelayCommand]
|
|
private async Task GradeSelected()
|
|
{
|
|
if (SelectedRow is null || OnGradeExam is null) return;
|
|
await OnGradeExam(SelectedRow.Exam);
|
|
Load();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task EvaluateSelected()
|
|
{
|
|
if (SelectedRow is null || OnEvaluateExam is null) return;
|
|
await OnEvaluateExam(SelectedRow.Exam);
|
|
Load();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void GoToGroup()
|
|
{
|
|
if (SelectedRow is null) return;
|
|
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
|
|
(e, v) => e.ApprovalGrantedAt = v);
|
|
|
|
[RelayCommand]
|
|
private void ToggleAnnouncement() => ToggleDate(SelectedRow?.Exam, e => e.AnnouncedAt,
|
|
(e, v) => e.AnnouncedAt = v);
|
|
|
|
private void ToggleDate(Exam? exam, Func<Exam, DateOnly?> get, Action<Exam, DateOnly?> set)
|
|
{
|
|
if (exam is null) return;
|
|
set(exam, get(exam) is null ? DateOnly.FromDateTime(DateTime.Today) : null);
|
|
exam.UpdatedAt = DateTime.UtcNow;
|
|
_exams.Save(exam);
|
|
UpdateDetail(SelectedRow);
|
|
}
|
|
}
|
|
|
|
// ── Zeile in der Klausurliste ────────────────────────────────────────────────
|
|
|
|
public class ExamListRowViewModel
|
|
{
|
|
public Exam Exam { get; }
|
|
public Guid GroupId { get; }
|
|
public string Title { get; }
|
|
public string GroupLabel { get; }
|
|
public int Expected { get; }
|
|
public int Evaluated { get; }
|
|
public ExamListStatus Status { get; }
|
|
public double Score { get; }
|
|
public string StatusLabel { get; }
|
|
public string SubLabel { get; }
|
|
public bool HasSibling { get; set; }
|
|
|
|
public bool IsOk { get; }
|
|
public bool IsInfo { get; }
|
|
public bool IsWarning { get; }
|
|
public bool IsDanger { get; }
|
|
|
|
public ExamListRowViewModel(Exam exam, LearningGroup group, int expected, int evaluated,
|
|
ExamPriorityInfo info, DateOnly today)
|
|
{
|
|
Exam = exam; GroupId = group.Id; Title = exam.Title; GroupLabel = group.Name;
|
|
Expected = expected; Evaluated = evaluated; Status = info.Status; Score = info.Score;
|
|
|
|
(StatusLabel, SubLabel, IsOk, IsInfo, IsWarning, IsDanger) = Status switch
|
|
{
|
|
ExamListStatus.Planned => ("Geplant", $"geplant für {RelativeDay(exam.Date, today)}",
|
|
false, false, false, false),
|
|
ExamListStatus.AwaitingCorrection when today.DayNumber - exam.Date.DayNumber <= 3 =>
|
|
("Korrektur ausstehend", $"geschrieben {RelativeDay(exam.Date, today)}",
|
|
false, false, true, false),
|
|
ExamListStatus.AwaitingCorrection =>
|
|
("überfällig", $"geschrieben {RelativeDay(exam.Date, today)}", false, false, false, true),
|
|
ExamListStatus.CorrectionInProgress =>
|
|
("Korrektur läuft", $"{evaluated}/{expected} korrigiert", false, false, true, false),
|
|
ExamListStatus.CorrectionStuck =>
|
|
("hängt fest", $"{evaluated}/{expected} korrigiert", false, false, false, false),
|
|
ExamListStatus.AwaitingReturn => ("Korrigiert", "Rückgabe aussteht", false, true, false, false),
|
|
ExamListStatus.Returned => ("Abgeschlossen",
|
|
exam.ReturnedAt is { } r ? $"zurückgegeben {r:dd.MM.yyyy}" : "zurückgegeben", true, false, false, false),
|
|
_ => ("", "", false, false, false, false),
|
|
};
|
|
}
|
|
|
|
private static string RelativeDay(DateOnly date, DateOnly today)
|
|
{
|
|
var diff = date.DayNumber - today.DayNumber;
|
|
return diff switch
|
|
{
|
|
0 => "heute",
|
|
1 => "morgen",
|
|
-1 => "gestern",
|
|
> 1 => $"in {diff} Tagen",
|
|
_ => $"vor {-diff} Tagen",
|
|
};
|
|
}
|
|
}
|