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:
2026-08-29 01:27:18 +02:00
co-authored by Claude Sonnet 5
parent 9b04d7eb98
commit 7fbf035cbb
16 changed files with 950 additions and 6 deletions
+2
View File
@@ -6,6 +6,7 @@ using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
@@ -306,6 +307,7 @@ public static class AppBootstrapper
services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
@@ -386,12 +386,10 @@ public partial class DashboardViewModel : ObservableObject
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
.OrderBy(e => e.Date))
{
var expected = _memberships.GetByGroup(group.Id)
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
var evaluated = _examResults.GetByExam(exam.Id)
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
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.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
@@ -52,6 +53,16 @@ public partial class ExamGradingDialogViewModel : ObservableObject
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 ──────────────────────────────────────────────────
@@ -21,6 +21,7 @@ public partial class ExamDialogViewModel : ObservableObject
private readonly int _gradeLevel;
private readonly GradingSystem _gradingSystem;
private readonly Exam? _editingExam;
private readonly Exam? _duplicateSource;
private readonly string _subjectName;
[ObservableProperty] private string _title = "";
@@ -68,6 +69,7 @@ public partial class ExamDialogViewModel : ObservableObject
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_gradingSystem = gradingSystem;
_editingExam = editingExam;
_duplicateSource = duplicateSource;
_subjectName = defaultSubjectName;
IsDifferentiated = isDifferentiated;
@@ -304,6 +306,11 @@ public partial class ExamDialogViewModel : ObservableObject
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
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);
}
}
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
@@ -90,6 +91,7 @@ public partial class MainWindowViewModel : ObservableObject
case WorkloadViewModel vm:
vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); 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;
// Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht
// laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog.
@@ -119,7 +121,7 @@ public partial class MainWindowViewModel : ObservableObject
NavItem.Dashboard => GetDashboard(),
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
NavItem.Students => GetStudents(),
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
NavItem.Exams => GetExams(),
NavItem.Planner => GetTimetable(),
NavItem.Workload => GetWorkload(),
NavItem.ClassTeacher => GetClassTeacherOverview(),
@@ -162,6 +164,13 @@ public partial class MainWindowViewModel : ObservableObject
return workload;
}
private ExamsOverviewViewModel GetExams()
{
var exams = _services.GetRequiredService<ExamsOverviewViewModel>();
exams.LoadCommand.Execute(null);
return exams;
}
private ClassTeacherOverviewViewModel GetClassTeacherOverview()
{
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}}">
<TextBlock Text="{Binding NiveauLabel}" FontSize="12" Foreground="White"/>
</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>
<DataGrid Grid.Row="1" Name="ResultGrid"
+5
View File
@@ -15,6 +15,8 @@
xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
xmlns:vct="clr-namespace:LehrerApp.Desktop.Views.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"
x:Class="LehrerApp.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
@@ -63,6 +65,9 @@
<DataTemplate DataType="vmct:ClassTeacherOverviewViewModel">
<vct:ClassTeacherOverviewView/>
</DataTemplate>
<DataTemplate DataType="vmex:ExamsOverviewViewModel">
<vex:ExamsOverviewView/>
</DataTemplate>
<DataTemplate DataType="vm:PlaceholderViewModel">
<views:PlaceholderView/>
</DataTemplate>