Klausuren-Hauptseite: gruppenübergreifende Liste mit Prioritäts-Score statt Datum
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>
This commit is contained in:
@@ -13,6 +13,12 @@ public class Exam
|
|||||||
public DateOnly? ReturnedAt { get; set; }
|
public DateOnly? ReturnedAt { get; set; }
|
||||||
public Niveau? Niveau { get; set; }
|
public Niveau? Niveau { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
public DateOnly? ApprovalGrantedAt { get; set; }
|
||||||
|
public DateOnly? AnnouncedAt { get; set; }
|
||||||
|
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
|
||||||
|
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
|
||||||
|
/// (siehe ExamDialogViewModel.Save).
|
||||||
|
public Guid? SharedExamGroupId { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen
|
||||||
|
/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald
|
||||||
|
/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von
|
||||||
|
/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren-
|
||||||
|
/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen.
|
||||||
|
public static class ExamCorrectionCounter
|
||||||
|
{
|
||||||
|
public static (int Expected, int Evaluated) Count(Exam exam, List<GroupMembership> groupMemberships,
|
||||||
|
List<ExamResult> examResults)
|
||||||
|
{
|
||||||
|
var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
||||||
|
var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
||||||
|
return (expected, Math.Min(evaluated, expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned }
|
||||||
|
|
||||||
|
public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score);
|
||||||
|
|
||||||
|
/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt
|
||||||
|
/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft"
|
||||||
|
/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe
|
||||||
|
/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer
|
||||||
|
/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der
|
||||||
|
/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben.
|
||||||
|
public static class ExamPriorityService
|
||||||
|
{
|
||||||
|
public const int StuckAfterDays = 21;
|
||||||
|
private const int CorrectionGraceDays = 3;
|
||||||
|
|
||||||
|
public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today)
|
||||||
|
{
|
||||||
|
if (exam.Status == ExamStatus.Returned)
|
||||||
|
return new ExamPriorityInfo(ExamListStatus.Returned, -100);
|
||||||
|
|
||||||
|
if (exam.Status == ExamStatus.Graded)
|
||||||
|
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||||
|
|
||||||
|
if (exam.Status == ExamStatus.Planned)
|
||||||
|
{
|
||||||
|
var daysUntil = exam.Date.DayNumber - today.DayNumber;
|
||||||
|
return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung.
|
||||||
|
var daysSince = today.DayNumber - exam.Date.DayNumber;
|
||||||
|
|
||||||
|
if (evaluated <= 0)
|
||||||
|
{
|
||||||
|
var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince;
|
||||||
|
return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected > 0 && evaluated >= expected)
|
||||||
|
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||||
|
|
||||||
|
if (daysSince > StuckAfterDays)
|
||||||
|
return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90);
|
||||||
|
|
||||||
|
return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass
|
||||||
|
/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen.
|
||||||
|
public sealed class ExamGradingDialogViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||||
|
var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" };
|
||||||
|
var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||||
|
var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" };
|
||||||
|
var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]);
|
||||||
|
var memberships = new FakeMemberships(
|
||||||
|
[
|
||||||
|
new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id },
|
||||||
|
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id },
|
||||||
|
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id },
|
||||||
|
]);
|
||||||
|
var exam = new Exam
|
||||||
|
{
|
||||||
|
GroupId = group.Id,
|
||||||
|
Title = "Klausur 1",
|
||||||
|
Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }],
|
||||||
|
GradingKey = GradingService.DefaultKey1To6(),
|
||||||
|
};
|
||||||
|
var results = new FakeResults();
|
||||||
|
|
||||||
|
var vm = new ExamGradingDialogViewModel(results, students, memberships,
|
||||||
|
new GradingService(), exam, group.Id);
|
||||||
|
|
||||||
|
var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id);
|
||||||
|
rowWithPoints.Cells[0].TrySetValue(7);
|
||||||
|
|
||||||
|
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.False(rowWithPoints.Absent);
|
||||||
|
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent);
|
||||||
|
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||||
|
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||||
|
var students = new FakeStudents([student]);
|
||||||
|
var memberships = new FakeMemberships(
|
||||||
|
[new GroupMembership { GroupId = group.Id, StudentId = student.Id }]);
|
||||||
|
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() };
|
||||||
|
var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships,
|
||||||
|
new GradingService(), exam, group.Id);
|
||||||
|
|
||||||
|
vm.Rows[0].Absent = true;
|
||||||
|
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.Rows[0].Absent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Exams;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht,
|
||||||
|
/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5).
|
||||||
|
public sealed class ExamsOverviewViewModelTests
|
||||||
|
{
|
||||||
|
private static ExamsOverviewViewModel BuildVm(List<LearningGroup> groups, List<Exam> exams,
|
||||||
|
FakeMemberships? memberships = null, FakeResults? results = null) =>
|
||||||
|
new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups),
|
||||||
|
memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) };
|
||||||
|
var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) };
|
||||||
|
|
||||||
|
var vm = BuildVm([group], [overdue, planned]);
|
||||||
|
vm.LoadCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal("Alte Klausur", vm.Rows[0].Title);
|
||||||
|
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||||
|
{
|
||||||
|
var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||||
|
var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear };
|
||||||
|
var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) };
|
||||||
|
var duplicate = new Exam
|
||||||
|
{
|
||||||
|
GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned,
|
||||||
|
Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = BuildVm([groupA, groupB], [original, duplicate]);
|
||||||
|
vm.LoadCommand.Execute(null);
|
||||||
|
|
||||||
|
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id);
|
||||||
|
Assert.True(vm.SelectedRow.HasSibling);
|
||||||
|
Assert.Single(vm.Siblings);
|
||||||
|
Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToggleApproval_SetztUndLöschtDatum()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||||
|
var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) };
|
||||||
|
var vm = BuildVm([group], [exam]);
|
||||||
|
vm.LoadCommand.Execute(null);
|
||||||
|
vm.SelectedRow = vm.Rows[0];
|
||||||
|
|
||||||
|
vm.ToggleApprovalCommand.Execute(null);
|
||||||
|
Assert.NotNull(exam.ApprovalGrantedAt);
|
||||||
|
Assert.StartsWith("Genehmigt am", vm.ApprovalLabel);
|
||||||
|
|
||||||
|
vm.ToggleApprovalCommand.Execute(null);
|
||||||
|
Assert.Null(exam.ApprovalGrantedAt);
|
||||||
|
Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) };
|
||||||
|
var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) };
|
||||||
|
var vm = BuildVm([group], [conducted, returned]);
|
||||||
|
vm.LoadCommand.Execute(null);
|
||||||
|
|
||||||
|
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id);
|
||||||
|
Assert.True(vm.ShowCorrectionProgress);
|
||||||
|
Assert.False(vm.ShowGradeSummary);
|
||||||
|
|
||||||
|
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id);
|
||||||
|
Assert.False(vm.ShowCorrectionProgress);
|
||||||
|
Assert.True(vm.ShowGradeSummary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using LehrerApp.Data.Repositories;
|
|||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Exams;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
@@ -306,6 +307,7 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<WorkloadViewModel>();
|
services.AddSingleton<WorkloadViewModel>();
|
||||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||||
|
services.AddSingleton<ExamsOverviewViewModel>();
|
||||||
|
|
||||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||||
services.AddTransient<GroupDetailViewModel>();
|
services.AddTransient<GroupDetailViewModel>();
|
||||||
|
|||||||
@@ -386,12 +386,10 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
|
||||||
.OrderBy(e => e.Date))
|
.OrderBy(e => e.Date))
|
||||||
{
|
{
|
||||||
var expected = _memberships.GetByGroup(group.Id)
|
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
|
||||||
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
|
||||||
var evaluated = _examResults.GetByExam(exam.Id)
|
|
||||||
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
|
||||||
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
|
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
|
||||||
group.Name, exam.Date, Math.Min(evaluated, expected), expected, today));
|
group.Name, exam.Date, evaluated, expected, today));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
@@ -52,6 +53,16 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
|||||||
|
|
||||||
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
|
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
|
||||||
|
|
||||||
|
/// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren
|
||||||
|
/// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand
|
||||||
|
/// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte
|
||||||
|
/// Zeilen an — bereits eingetragene Punkte/Kommentare bleiben unangetastet.
|
||||||
|
[RelayCommand]
|
||||||
|
private void MarkRemainingAbsent()
|
||||||
|
{
|
||||||
|
foreach (var row in Rows.Where(r => !r.Absent && r.Cells.All(c => c.Value is null)))
|
||||||
|
row.Absent = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
|
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
private readonly int _gradeLevel;
|
private readonly int _gradeLevel;
|
||||||
private readonly GradingSystem _gradingSystem;
|
private readonly GradingSystem _gradingSystem;
|
||||||
private readonly Exam? _editingExam;
|
private readonly Exam? _editingExam;
|
||||||
|
private readonly Exam? _duplicateSource;
|
||||||
private readonly string _subjectName;
|
private readonly string _subjectName;
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
@@ -68,6 +69,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
||||||
_gradingSystem = gradingSystem;
|
_gradingSystem = gradingSystem;
|
||||||
_editingExam = editingExam;
|
_editingExam = editingExam;
|
||||||
|
_duplicateSource = duplicateSource;
|
||||||
_subjectName = defaultSubjectName;
|
_subjectName = defaultSubjectName;
|
||||||
IsDifferentiated = isDifferentiated;
|
IsDifferentiated = isDifferentiated;
|
||||||
|
|
||||||
@@ -304,6 +306,11 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
|
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
|
||||||
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
||||||
Result.GradingKey = gradingKey;
|
Result.GradingKey = gradingKey;
|
||||||
|
// Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die
|
||||||
|
// Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst
|
||||||
|
// angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter.
|
||||||
|
if (_duplicateSource is not null)
|
||||||
|
Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id;
|
||||||
_exams.Save(Result);
|
_exams.Save(Result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Exams;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
@@ -90,6 +91,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
case WorkloadViewModel vm:
|
case WorkloadViewModel vm:
|
||||||
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break;
|
||||||
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
case ClassTeacherOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||||
|
case ExamsOverviewViewModel vm: vm.LoadCommand.Execute(null); break;
|
||||||
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break;
|
||||||
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
|
||||||
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
|
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
|
||||||
@@ -119,7 +121,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
NavItem.Dashboard => GetDashboard(),
|
NavItem.Dashboard => GetDashboard(),
|
||||||
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
||||||
NavItem.Students => GetStudents(),
|
NavItem.Students => GetStudents(),
|
||||||
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
|
NavItem.Exams => GetExams(),
|
||||||
NavItem.Planner => GetTimetable(),
|
NavItem.Planner => GetTimetable(),
|
||||||
NavItem.Workload => GetWorkload(),
|
NavItem.Workload => GetWorkload(),
|
||||||
NavItem.ClassTeacher => GetClassTeacherOverview(),
|
NavItem.ClassTeacher => GetClassTeacherOverview(),
|
||||||
@@ -162,6 +164,13 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
return workload;
|
return workload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ExamsOverviewViewModel GetExams()
|
||||||
|
{
|
||||||
|
var exams = _services.GetRequiredService<ExamsOverviewViewModel>();
|
||||||
|
exams.LoadCommand.Execute(null);
|
||||||
|
return exams;
|
||||||
|
}
|
||||||
|
|
||||||
private ClassTeacherOverviewViewModel GetClassTeacherOverview()
|
private ClassTeacherOverviewViewModel GetClassTeacherOverview()
|
||||||
{
|
{
|
||||||
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
var vm = _services.GetRequiredService<ClassTeacherOverviewViewModel>();
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Exams"
|
||||||
|
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Exams.ExamsOverviewView"
|
||||||
|
x:DataType="vm:ExamsOverviewViewModel">
|
||||||
|
|
||||||
|
<!-- Statusfarben kommen wie im Klassenlehrer-Bereich aus Styles/SemanticBrushes.axaml (siehe
|
||||||
|
ClassTeacherOverviewView) — Basis ist hier aber "neutral" statt "ok", weil geplante bzw.
|
||||||
|
hängengebliebene Klausuren (ExamPriorityService.CorrectionStuck) hier den Normalfall
|
||||||
|
bilden, nicht ein positives Ergebnis. -->
|
||||||
|
<UserControl.Styles>
|
||||||
|
<Style Selector="Border.examStatusBar">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppListRowBorderBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusBar.ok">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusBar.info">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusBar.warning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusBar.danger">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
|
||||||
|
<Setter Property="CornerRadius" Value="4"/>
|
||||||
|
<Setter Property="Padding" Value="8,2"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.ok">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusOkBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.ok TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.info">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusInfoBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.info TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.warning">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusWarningBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.warning TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.danger">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.examStatusPill.danger TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.examRow">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1,0,1,1"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
<Setter Property="CornerRadius" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.examRow:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.siblingPill">
|
||||||
|
<Setter Property="Padding" Value="10,4"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="CornerRadius" Value="12"/>
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*">
|
||||||
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<shared:PageHeader Grid.Column="0" Title="Klausuren"
|
||||||
|
Subtitle="Alle Klausuren des Schuljahres, nach Dringlichkeit sortiert"/>
|
||||||
|
<Button Grid.Column="1" Content="↻ Aktualisieren" Command="{Binding LoadCommand}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Detailbereich zur ausgewählten Klausur ─────────────────────────────────── -->
|
||||||
|
<Border Grid.Row="1" Margin="20,16,20,10" Padding="18"
|
||||||
|
Background="{DynamicResource AppCardBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="10"
|
||||||
|
IsVisible="{Binding HasSelection}">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
|
<TextBlock Text="{Binding SelectedRow.Title}" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock FontSize="13" Opacity="0.6">
|
||||||
|
<Run Text="{Binding SelectedRow.GroupLabel}"/>
|
||||||
|
<Run Text=" · "/>
|
||||||
|
<Run Text="{Binding SelectedRow.SubLabel}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
<Border Grid.Column="1" Classes="examStatusPill" VerticalAlignment="Top"
|
||||||
|
Classes.ok="{Binding SelectedRow.IsOk}" Classes.info="{Binding SelectedRow.IsInfo}"
|
||||||
|
Classes.warning="{Binding SelectedRow.IsWarning}" Classes.danger="{Binding SelectedRow.IsDanger}">
|
||||||
|
<TextBlock Text="{Binding SelectedRow.StatusLabel}"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Parallelkurs-Umschalter -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Siblings}" IsVisible="{Binding SelectedRow.HasSibling}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ExamListRowViewModel">
|
||||||
|
<Button Classes="siblingPill"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:ExamsOverviewViewModel)DataContext).SelectExamCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<TextBlock>
|
||||||
|
<Run Text="{Binding GroupLabel}"/>
|
||||||
|
<Run Text=" · "/>
|
||||||
|
<Run Text="{Binding Evaluated}"/>
|
||||||
|
<Run Text="/"/>
|
||||||
|
<Run Text="{Binding Expected}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- Korrekturfortschritt -->
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding ShowCorrectionProgress}">
|
||||||
|
<TextBlock Text="{Binding ProgressLabel}" FontSize="13"/>
|
||||||
|
<Border Width="240" Height="6" CornerRadius="3" HorizontalAlignment="Left"
|
||||||
|
Background="{DynamicResource AppTrackBackgroundBrush}">
|
||||||
|
<Border Width="{Binding ProgressBarWidth}" Height="6" CornerRadius="3"
|
||||||
|
HorizontalAlignment="Left" Background="{DynamicResource AppStatusWarningBrush}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Notenspiegel -->
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding ShowGradeSummary}">
|
||||||
|
<TextBlock Text="{Binding AverageLabel}" FontSize="14" FontWeight="SemiBold"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding DetailGradeDistribution}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vmg:GradeBarItem">
|
||||||
|
<Grid ColumnDefinitions="30,*,60" Margin="0,1">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Grade}" FontSize="12" VerticalAlignment="Center"/>
|
||||||
|
<Border Grid.Column="1" Height="14" Width="{Binding BarWidth}" HorizontalAlignment="Left"
|
||||||
|
Background="{DynamicResource AppAccentTextBrush}" CornerRadius="3"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding CountDisplay}" FontSize="11" Opacity="0.6"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Genehmigung / Ankündigung -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding ApprovalLabel}" Command="{Binding ToggleApprovalCommand}" FontSize="12"/>
|
||||||
|
<Button Content="{Binding AnnouncementLabel}" Command="{Binding ToggleAnnouncementCommand}" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Punkte eingeben" Command="{Binding GradeSelectedCommand}"
|
||||||
|
IsVisible="{Binding ShowCorrectionProgress}"/>
|
||||||
|
<Button Content="Auswertung" Command="{Binding EvaluateSelectedCommand}"
|
||||||
|
IsVisible="{Binding ShowGradeSummary}"/>
|
||||||
|
<Button Content="Zum Kurs →" Command="{Binding GoToGroupCommand}" HorizontalAlignment="Right"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Liste aller Klausuren, nach Priorität sortiert (nicht nach Datum) ─────────── -->
|
||||||
|
<ScrollViewer Grid.Row="2" Margin="20,0,20,16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding EmptyHint}" Classes="emptyhint" Margin="0,20,0,0"
|
||||||
|
IsVisible="{Binding EmptyHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ExamListRowViewModel">
|
||||||
|
<Button Classes="examRow"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:ExamsOverviewViewModel)DataContext).SelectExamCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="4,*,Auto,Auto" MinHeight="52">
|
||||||
|
<Border Grid.Column="0" Classes="examStatusBar" Classes.ok="{Binding IsOk}"
|
||||||
|
Classes.info="{Binding IsInfo}" Classes.warning="{Binding IsWarning}"
|
||||||
|
Classes.danger="{Binding IsDanger}"/>
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="12,6" Spacing="2">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="14" FontWeight="Medium"/>
|
||||||
|
<TextBlock Text="🔗" FontSize="11" Opacity="0.5" IsVisible="{Binding HasSibling}"
|
||||||
|
ToolTip.Tip="Parallelkurs vorhanden"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock FontSize="12" Opacity="0.6">
|
||||||
|
<Run Text="{Binding GroupLabel}"/>
|
||||||
|
<Run Text=" · "/>
|
||||||
|
<Run Text="{Binding SubLabel}"/>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
<Border Grid.Column="3" Classes="examStatusPill" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
|
Classes.ok="{Binding IsOk}" Classes.info="{Binding IsInfo}"
|
||||||
|
Classes.warning="{Binding IsWarning}" Classes.danger="{Binding IsDanger}">
|
||||||
|
<TextBlock Text="{Binding StatusLabel}"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Exams;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.Views.Groups;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Exams;
|
||||||
|
|
||||||
|
public partial class ExamsOverviewView : UserControl
|
||||||
|
{
|
||||||
|
private const int GroupDetailKlausurenTabIndex = 4;
|
||||||
|
|
||||||
|
public ExamsOverviewView() => InitializeComponent();
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is ExamsOverviewViewModel vm)
|
||||||
|
{
|
||||||
|
vm.OnGradeExam = ShowGradeExamDialog;
|
||||||
|
vm.OnEvaluateExam = ShowEvaluateExamDialog;
|
||||||
|
vm.OnNavigateToGroup = groupId => App.Services.GetRequiredService<MainWindowViewModel>()
|
||||||
|
.NavigateToGroupDetail(groupId, GroupDetailKlausurenTabIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowGradeExamDialog(Exam exam)
|
||||||
|
{
|
||||||
|
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
|
||||||
|
if (group is null) return;
|
||||||
|
|
||||||
|
var dialogVm = new ExamGradingDialogViewModel(
|
||||||
|
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||||
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
|
App.Services.GetRequiredService<GradingService>(),
|
||||||
|
exam, group.Id);
|
||||||
|
|
||||||
|
var dialog = new ExamGradingDialog { DataContext = dialogVm };
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is not null) await dialog.ShowDialog(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowEvaluateExamDialog(Exam exam)
|
||||||
|
{
|
||||||
|
var group = App.Services.GetRequiredService<IGroupRepository>().GetById(exam.GroupId);
|
||||||
|
if (group is null) return;
|
||||||
|
|
||||||
|
var dialogVm = new ExamEvaluationDialogViewModel(
|
||||||
|
App.Services.GetRequiredService<IExamRepository>(),
|
||||||
|
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||||
|
App.Services.GetRequiredService<GradingService>(),
|
||||||
|
exam, group.GradingSystem);
|
||||||
|
|
||||||
|
var dialog = new ExamEvaluationDialog { DataContext = dialogVm };
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is not null) await dialog.ShowDialog(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding NiveauLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
|
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
<Button Content="Rest als abwesend markieren" Command="{Binding MarkRemainingAbsentCommand}"
|
||||||
|
Margin="20,0,0,0" VerticalAlignment="Center"
|
||||||
|
ToolTip.Tip="Setzt bei allen noch leeren Zeilen 'Abwesend' — für Schüler, die nicht nachschreiben."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<DataGrid Grid.Row="1" Name="ResultGrid"
|
<DataGrid Grid.Row="1" Name="ResultGrid"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
||||||
xmlns:vct="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
|
xmlns:vct="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
|
||||||
xmlns:vmct="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
xmlns:vmct="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
|
||||||
|
xmlns:vex="clr-namespace:LehrerApp.Desktop.Views.Exams"
|
||||||
|
xmlns:vmex="clr-namespace:LehrerApp.Desktop.ViewModels.Exams"
|
||||||
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||||
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
@@ -63,6 +65,9 @@
|
|||||||
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
|
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
|
||||||
<vct:ClassTeacherOverviewView/>
|
<vct:ClassTeacherOverviewView/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
<DataTemplate DataType="vmex:ExamsOverviewViewModel">
|
||||||
|
<vex:ExamsOverviewView/>
|
||||||
|
</DataTemplate>
|
||||||
<DataTemplate DataType="vm:PlaceholderViewModel">
|
<DataTemplate DataType="vm:PlaceholderViewModel">
|
||||||
<views:PlaceholderView/>
|
<views:PlaceholderView/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Tests;
|
||||||
|
|
||||||
|
public class ExamPriorityServiceTests
|
||||||
|
{
|
||||||
|
private static Exam MakeExam(ExamStatus status, DateOnly date) => new()
|
||||||
|
{
|
||||||
|
GroupId = Guid.NewGuid(), Title = "Klausur", Status = status, Date = date,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_UnbearbeiteteKorrekturSteigtMitAlter()
|
||||||
|
{
|
||||||
|
var today = new DateOnly(2026, 8, 29);
|
||||||
|
var exam = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
|
||||||
|
|
||||||
|
var result = ExamPriorityService.Evaluate(exam, expected: 20, evaluated: 0, today);
|
||||||
|
|
||||||
|
Assert.Equal(ExamListStatus.AwaitingCorrection, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_UnbearbeiteteKorrekturIstDringenderAlsLaufendeKorrektur()
|
||||||
|
{
|
||||||
|
var today = new DateOnly(2026, 8, 29);
|
||||||
|
var untouched = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
|
||||||
|
var inProgress = MakeExam(ExamStatus.Conducted, today.AddDays(-10));
|
||||||
|
|
||||||
|
var untouchedResult = ExamPriorityService.Evaluate(untouched, expected: 20, evaluated: 0, today);
|
||||||
|
var progressResult = ExamPriorityService.Evaluate(inProgress, expected: 20, evaluated: 10, today);
|
||||||
|
|
||||||
|
Assert.True(untouchedResult.Score > progressResult.Score);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_LaufendeKorrekturIstDringenderAlsGeplanteKlausur()
|
||||||
|
{
|
||||||
|
var today = new DateOnly(2026, 8, 29);
|
||||||
|
var inProgress = MakeExam(ExamStatus.Conducted, today.AddDays(-2));
|
||||||
|
var planned = MakeExam(ExamStatus.Planned, today.AddDays(1));
|
||||||
|
|
||||||
|
var progressResult = ExamPriorityService.Evaluate(inProgress, expected: 20, evaluated: 10, today);
|
||||||
|
var plannedResult = ExamPriorityService.Evaluate(planned, expected: 20, evaluated: 0, today);
|
||||||
|
|
||||||
|
Assert.True(progressResult.Score > plannedResult.Score);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_HängengebliebeneKorrekturFälltAusDerDringlichenZone()
|
||||||
|
{
|
||||||
|
var today = new DateOnly(2026, 8, 29);
|
||||||
|
var stuck = MakeExam(ExamStatus.Conducted, today.AddDays(-(ExamPriorityService.StuckAfterDays + 5)));
|
||||||
|
var fresh = MakeExam(ExamStatus.Conducted, today.AddDays(-2));
|
||||||
|
|
||||||
|
// Ein einzelner nie nachschreibender Schüler: 19 von 20 erledigt, seit Wochen unverändert.
|
||||||
|
var stuckResult = ExamPriorityService.Evaluate(stuck, expected: 20, evaluated: 19, today);
|
||||||
|
var freshResult = ExamPriorityService.Evaluate(fresh, expected: 20, evaluated: 10, today);
|
||||||
|
|
||||||
|
Assert.Equal(ExamListStatus.CorrectionStuck, stuckResult.Status);
|
||||||
|
Assert.True(stuckResult.Score < freshResult.Score);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_VollständigBewertetGiltAlsBereitZurRückgabe()
|
||||||
|
{
|
||||||
|
var today = new DateOnly(2026, 8, 29);
|
||||||
|
var exam = MakeExam(ExamStatus.Conducted, today.AddDays(-5));
|
||||||
|
|
||||||
|
var result = ExamPriorityService.Evaluate(exam, expected: 20, evaluated: 20, today);
|
||||||
|
|
||||||
|
Assert.Equal(ExamListStatus.AwaitingReturn, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ZurückgegebenIstAmWenigstenDringlich()
|
||||||
|
{
|
||||||
|
var today = new DateOnly(2026, 8, 29);
|
||||||
|
var returned = MakeExam(ExamStatus.Returned, today.AddDays(-30));
|
||||||
|
var planned = MakeExam(ExamStatus.Planned, today.AddDays(60));
|
||||||
|
|
||||||
|
var returnedResult = ExamPriorityService.Evaluate(returned, expected: 20, evaluated: 20, today);
|
||||||
|
var plannedResult = ExamPriorityService.Evaluate(planned, expected: 20, evaluated: 0, today);
|
||||||
|
|
||||||
|
Assert.True(returnedResult.Score < plannedResult.Score);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Count_AbwesenderSchülerGiltAlsErledigt()
|
||||||
|
{
|
||||||
|
var exam = MakeExam(ExamStatus.Conducted, new DateOnly(2026, 8, 20));
|
||||||
|
var membership = new GroupMembership { GroupId = exam.GroupId, StudentId = Guid.NewGuid() };
|
||||||
|
var results = new List<ExamResult> { new() { ExamId = exam.Id, StudentId = membership.StudentId, Absent = true } };
|
||||||
|
|
||||||
|
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, [membership], results);
|
||||||
|
|
||||||
|
Assert.Equal(1, expected);
|
||||||
|
Assert.Equal(1, evaluated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,48 @@ pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene
|
|||||||
- [x] **1.5.4** Export der Auswertung — CSV-Export direkt im Dialog; inzwischen auf die gemeinsame
|
- [x] **1.5.4** Export der Auswertung — CSV-Export direkt im Dialog; inzwischen auf die gemeinsame
|
||||||
Export-Infrastruktur aus 11.1 und den zentralen `CsvBuilder` umgestellt.
|
Export-Infrastruktur aus 11.1 und den zentralen `CsvBuilder` umgestellt.
|
||||||
|
|
||||||
|
### 1.6 Klausuren-Hauptseite (gruppenübergreifend)
|
||||||
|
|
||||||
|
Der Sidebar-Punkt "Klausuren" (`NavItem.Exams`) war bisher ein `PlaceholderViewModel` ohne
|
||||||
|
Funktion — die vollständige Verwaltung existierte nur innerhalb einer Gruppe (1.1–1.5). Nutzer
|
||||||
|
unterrichten oft mehrere Kurse gleichzeitig und brauchen einen gruppenübergreifenden Überblick.
|
||||||
|
|
||||||
|
- [x] **1.6.1** Neue Seite `ExamsOverviewViewModel`/`ExamsOverviewView` ersetzt den Placeholder.
|
||||||
|
Detailbereich zur ausgewählten Klausur oben (ca. 55–60 % Höhe), darunter eine scrollbare
|
||||||
|
Liste aller Klausuren des aktuellen Schuljahres — bewusst kein separates Menü, sondern
|
||||||
|
Bestandteil derselben Seite (Nutzerwunsch: "keine eigene Hauptseite in dem Ausmaß").
|
||||||
|
- [x] **1.6.2** Sortierung nach unsichtbarem Prioritäts-Score statt nach Datum — neuer Service
|
||||||
|
`ExamPriorityService` (`LehrerApp.Core/Services/ExamPriorityService.cs`) leitet Status
|
||||||
|
(`ExamListStatus`: Planned/AwaitingCorrection/CorrectionInProgress/CorrectionStuck/
|
||||||
|
AwaitingReturn/Returned) und Score aus dem vorhandenen `Exam.Status` plus dem
|
||||||
|
Korrekturfortschritt ab — kein neuer, manuell zu pflegender "Korrektur läuft"-Status.
|
||||||
|
Der Korrekturfortschritt selbst kommt aus `ExamCorrectionCounter` (ebenda), das auch
|
||||||
|
`DashboardViewModel.LoadOpenCorrections` jetzt nutzt (vorher zwei Kopien derselben Zählung).
|
||||||
|
Farbcodierung wiederverwendet die validierten `AppStatus{Ok,Info,Warning,Danger}Brush`
|
||||||
|
aus `Styles/SemanticBrushes.axaml` (Klassenlehrer-Bereich) statt neuer Hex-Werte.
|
||||||
|
- [x] **1.6.3** Nutzer-Feedback zum "hängt fest"-Fall: ein einzelner Schüler, der nie
|
||||||
|
nachschreibt, blockierte sonst dauerhaft die live abgeleitete Korrektur-Erkennung, weil
|
||||||
|
niemand eine leere Zeile anfasst, für die es nichts einzutragen gibt. Zwei Bausteine statt
|
||||||
|
eines manuellen Override: (a) neuer Button "Rest als abwesend markieren" im
|
||||||
|
`ExamGradingDialog` setzt bei allen noch unberührten Zeilen "Abwesend" in einem Klick;
|
||||||
|
(b) bleibt eine Klausur trotzdem länger als `ExamPriorityService.StuckAfterDays` (21 Tage)
|
||||||
|
in Bearbeitung hängen, fällt sie aus der dringenden Zone in eine ruhige
|
||||||
|
`CorrectionStuck`-Einstufung statt dauerhaft oben zu kleben.
|
||||||
|
- [x] **1.6.4** Parallelkurse (gleiche Arbeit, mehrere Kurse — z.B. zwei Chemie-Kurse im selben
|
||||||
|
Jahrgang): neues optionales Feld `Exam.SharedExamGroupId`, beim Duplizieren (1.1.4) auf die
|
||||||
|
Id der Ursprungsklausur gesetzt. Die Klausuren-Hauptseite zeigt dafür einen Kurs-Umschalter
|
||||||
|
im Detailbereich; Zeilen mit Verknüpfung tragen ein 🔗-Symbol in der Liste.
|
||||||
|
- [x] **1.6.5** Zwei neue nullable Datumsfelder `Exam.ApprovalGrantedAt`/`AnnouncedAt`
|
||||||
|
("Genehmigung erteilt"/"Ankündigung bei Schülern gemacht", Label verhandelbar) — bewusst
|
||||||
|
keine weiteren Workflow-Statusstufen, weil sie zeitlich oft parallel statt sequenziell zum
|
||||||
|
Hauptstatus laufen. Als anklickbare Zeilen im Detailbereich, die das Datum auf heute setzen
|
||||||
|
bzw. wieder löschen.
|
||||||
|
- [x] **1.6.6** Detailbereich zeigt je nach Status Korrekturfortschritt (Balken + Zahl) oder
|
||||||
|
Notenspiegel (Balken je Note + Durchschnitt, gleiche Berechnung wie 1.5.1 über
|
||||||
|
`GradingService`/`GradeBarItem`) — kein Kompetenz-Breakdown in dieser Iteration. Button
|
||||||
|
"Zum Kurs" springt in den Klausuren-Tab der jeweiligen Gruppe (dort wie gehabt Zugriff auf
|
||||||
|
Kompetenzen-Tab, 8.2/8.3).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Noten & Zeugnisnoten
|
## 2. Noten & Zeugnisnoten
|
||||||
|
|||||||
Reference in New Issue
Block a user