Punkteeingabe & Korrektur für Klausuren (1.4)
Neuer Dialog ExamGradingDialog, erreichbar über "Punkte eingeben" im Klausuren-Tab (Button + Kontextmenü bei ausgewählter Klausur): - Eingaberaster: Schüler-Zeilen × Aufgaben-Spalten, Summe und Note live über GradingService.CalculateGrade() berechnet. - Tab bewegt sich nativ zur nächsten Zelle; Enter/Pfeil-Hoch/Pfeil-Runter springen zur gleichen Spalte in der Nachbarzeile (auch über noch nicht realisierte, virtualisierte Zeilen hinweg via ScrollIntoView). Komma oder Punkt als Dezimaltrennzeichen werden beide akzeptiert. - Abwesend-Checkbox (Note zeigt dann "abwesend") und Kommentarfeld pro Schüler. - Punkte außerhalb 0..Maximalpunkte werden rot markiert und bewusst nicht gespeichert; der zuletzt gültige Stand bleibt erhalten. - Autosave nach jeder Zelle, kein Speichern-Button nötig. Dabei nebenbei behoben: die Schülerliste berücksichtigt jetzt Enrollment- Zeiträume (H1/H2/Custom) relativ zum Klausurdatum, analog zur bereits bestehenden Logik im Mitarbeit-Tab, statt pauschal alle im Schuljahr eingeschriebenen Schüler zu zeigen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
// ── Punkteeingabe & Korrektur (1.4) ──────────────────────────────────────────
|
||||
|
||||
public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Exam _exam;
|
||||
private readonly double _examMaxPoints;
|
||||
|
||||
public string ExamTitle => _exam.Title;
|
||||
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
|
||||
public List<ExamTask> Tasks { get; }
|
||||
|
||||
public ObservableCollection<ExamResultRow> Rows { get; } = [];
|
||||
|
||||
public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students,
|
||||
IEnrollmentRepository enrollments, GradingService grading, Exam exam, Guid groupId, string schoolYear)
|
||||
{
|
||||
_results = results; _grading = grading; _exam = exam;
|
||||
Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
|
||||
_examMaxPoints = Tasks.Sum(t => t.MaxPoints);
|
||||
|
||||
var enrolled = students.GetByGroup(groupId, schoolYear);
|
||||
var enrollmentList = enrollments.GetByGroupAndYear(groupId, schoolYear);
|
||||
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
|
||||
|
||||
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
|
||||
{
|
||||
var enrollment = enrollmentList.FirstOrDefault(e => e.StudentId == s.Id);
|
||||
if (enrollment is not null && !IsEnrolledAtDate(enrollment, exam.Date)) continue;
|
||||
|
||||
existing.TryGetValue(s.Id, out var result);
|
||||
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, _examMaxPoints, _grading);
|
||||
row.OnChanged = SaveRow;
|
||||
Rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
|
||||
|
||||
private static bool IsEnrolledAtDate(Enrollment e, DateOnly date) => e.Period switch
|
||||
{
|
||||
EnrollmentPeriod.H1Only => date.Month >= 8 || date.Month <= 1,
|
||||
EnrollmentPeriod.H2Only => date.Month >= 2 && date.Month <= 7,
|
||||
EnrollmentPeriod.Custom => (e.JoinedAt is null || date >= e.JoinedAt.Value)
|
||||
&& (e.LeftAt is null || date <= e.LeftAt.Value),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Zeile im Punkteraster ──────────────────────────────────────────────────
|
||||
|
||||
public partial class ExamResultRow : ObservableObject
|
||||
{
|
||||
private readonly Guid _resultId;
|
||||
private readonly List<GradingKeyEntry> _gradingKey;
|
||||
private readonly double _examMaxPoints;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public Guid StudentId { get; }
|
||||
public string Name { get; }
|
||||
|
||||
public ObservableCollection<PointsCell> Cells { get; } = [];
|
||||
|
||||
[ObservableProperty] private bool _absent;
|
||||
[ObservableProperty] private string _comment = "";
|
||||
[ObservableProperty] private double _totalPoints;
|
||||
[ObservableProperty] private string _gradeDisplay = "";
|
||||
|
||||
public string TotalPointsDisplay => TotalPoints.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
public Action<ExamResultRow>? OnChanged { get; set; }
|
||||
|
||||
public ExamResultRow(Guid studentId, string name, List<ExamTask> tasks, ExamResult? existing,
|
||||
List<GradingKeyEntry> gradingKey, double examMaxPoints, GradingService grading)
|
||||
{
|
||||
StudentId = studentId; Name = name;
|
||||
_gradingKey = gradingKey; _examMaxPoints = examMaxPoints; _grading = grading;
|
||||
_resultId = existing?.Id ?? Guid.NewGuid();
|
||||
_absent = existing?.Absent ?? false;
|
||||
_comment = existing?.Comment ?? "";
|
||||
|
||||
for (var i = 0; i < tasks.Count; i++)
|
||||
{
|
||||
double? pts = existing is not null && i < existing.Points.Count ? existing.Points[i] : null;
|
||||
var cell = new PointsCell(tasks[i].MaxPoints, pts);
|
||||
cell.OnChanged = () => { RecomputeTotals(); OnChanged?.Invoke(this); };
|
||||
Cells.Add(cell);
|
||||
}
|
||||
RecomputeTotals();
|
||||
}
|
||||
|
||||
partial void OnAbsentChanged(bool value)
|
||||
{
|
||||
RecomputeTotals();
|
||||
OnChanged?.Invoke(this);
|
||||
}
|
||||
|
||||
partial void OnCommentChanged(string value) => OnChanged?.Invoke(this);
|
||||
|
||||
private void RecomputeTotals()
|
||||
{
|
||||
TotalPoints = Cells.Sum(c => c.IsInvalid ? 0 : (c.Value ?? 0));
|
||||
OnPropertyChanged(nameof(TotalPointsDisplay));
|
||||
GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints, _examMaxPoints, _gradingKey);
|
||||
}
|
||||
|
||||
public ExamResult ToModel(Guid examId) => new()
|
||||
{
|
||||
Id = _resultId,
|
||||
ExamId = examId,
|
||||
StudentId = StudentId,
|
||||
Points = Cells.Select(c => c.Value ?? 0).ToList(),
|
||||
TotalPoints = TotalPoints,
|
||||
Grade = Absent ? null : GradeDisplay,
|
||||
Absent = Absent,
|
||||
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Eine Punktezelle ─────────────────────────────────────────────────────────
|
||||
|
||||
public partial class PointsCell : ObservableObject
|
||||
{
|
||||
public double MaxPoints { get; }
|
||||
[ObservableProperty] private double? _value;
|
||||
[ObservableProperty] private bool _isInvalid;
|
||||
|
||||
public Action? OnChanged { get; set; }
|
||||
|
||||
public PointsCell(double maxPoints, double? value)
|
||||
{
|
||||
MaxPoints = maxPoints;
|
||||
_value = value;
|
||||
_isInvalid = IsOutOfRange(value);
|
||||
}
|
||||
|
||||
/// Setzt den Wert; bei Punkten außerhalb 0..Maximalpunkte wird nur rot markiert,
|
||||
/// aber nicht gespeichert (1.4.5) — OnChanged (und damit Autosave) wird dann nicht ausgelöst.
|
||||
public void TrySetValue(double? value)
|
||||
{
|
||||
Value = value;
|
||||
IsInvalid = IsOutOfRange(value);
|
||||
if (!IsInvalid) OnChanged?.Invoke();
|
||||
}
|
||||
|
||||
private bool IsOutOfRange(double? value) =>
|
||||
value.HasValue && (value.Value < 0 || value.Value > MaxPoints + 0.0001);
|
||||
}
|
||||
@@ -176,6 +176,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
public Func<Exam, Task<bool>>? OnEditExam { get; set; }
|
||||
public Func<Exam, Task<bool>>? OnDuplicateExam { get; set; }
|
||||
public Func<ExamSummary, Task<bool>>? OnConfirmDeleteExam { get; set; }
|
||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||
|
||||
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
|
||||
IEnrollmentRepository enrollments, IExamRepository exams, IGradeRepository grades,
|
||||
@@ -276,6 +277,15 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
private async Task GradeExam()
|
||||
{
|
||||
if (SelectedExam is null || OnGradeExam is null) return;
|
||||
var exam = _exams.GetById(SelectedExam.Id);
|
||||
if (exam is null) return;
|
||||
await OnGradeExam(exam);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
||||
private async Task DuplicateExam()
|
||||
{
|
||||
@@ -330,6 +340,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
partial void OnSelectedExamChanged(ExamSummary? value)
|
||||
{
|
||||
EditExamCommand.NotifyCanExecuteChanged();
|
||||
GradeExamCommand.NotifyCanExecuteChanged();
|
||||
DuplicateExamCommand.NotifyCanExecuteChanged();
|
||||
DeleteExamCommand.NotifyCanExecuteChanged();
|
||||
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.ExamGradingDialog"
|
||||
x:DataType="vm:ExamGradingDialogViewModel"
|
||||
Title="{Binding ExamTitle}"
|
||||
Width="1000" Height="700" MinWidth="700" MinHeight="420"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" Margin="20">
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="10" Margin="0,0,0,10">
|
||||
<TextBlock Text="{Binding ExamTitle}" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding ExamDateLabel}" FontSize="13" Opacity="0.6" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid Grid.Row="1" Name="ResultGrid"
|
||||
ItemsSource="{Binding Rows}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="False"
|
||||
GridLinesVisibility="All"
|
||||
CanUserReorderColumns="False"
|
||||
CanUserResizeColumns="True"/>
|
||||
|
||||
<Button Grid.Row="2" Content="Fertig" HorizontalAlignment="Right" Margin="0,12,0,0" Click="OnClose"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,185 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class ExamGradingDialog : Window
|
||||
{
|
||||
private static readonly IBrush InvalidBackground = new SolidColorBrush(Color.Parse("#FFCDD2"));
|
||||
|
||||
public ExamGradingDialog() => InitializeComponent();
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
if (DataContext is ExamGradingDialogViewModel vm) BuildColumns(vm);
|
||||
}
|
||||
|
||||
private void BuildColumns(ExamGradingDialogViewModel vm)
|
||||
{
|
||||
var grid = this.FindControl<DataGrid>("ResultGrid");
|
||||
if (grid is null) return;
|
||||
|
||||
grid.Columns.Clear();
|
||||
|
||||
grid.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = "Schüler",
|
||||
Binding = new Binding("Name"),
|
||||
IsReadOnly = true,
|
||||
Width = new DataGridLength(170, DataGridLengthUnitType.Pixel),
|
||||
});
|
||||
|
||||
for (var i = 0; i < vm.Tasks.Count; i++)
|
||||
{
|
||||
var task = vm.Tasks[i];
|
||||
grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}",
|
||||
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
|
||||
CellTemplate = BuildPointsCellTemplate(i, grid),
|
||||
});
|
||||
}
|
||||
|
||||
grid.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = "Summe", Binding = new Binding("TotalPointsDisplay"), IsReadOnly = true,
|
||||
Width = new DataGridLength(70, DataGridLengthUnitType.Pixel),
|
||||
});
|
||||
grid.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = "Note", Binding = new Binding("GradeDisplay"), IsReadOnly = true,
|
||||
Width = new DataGridLength(70, DataGridLengthUnitType.Pixel),
|
||||
});
|
||||
grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = "Abw.", Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
|
||||
CellTemplate = BuildAbsentCellTemplate(),
|
||||
});
|
||||
grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = "Kommentar", Width = new DataGridLength(180, DataGridLengthUnitType.Pixel),
|
||||
CellTemplate = BuildCommentCellTemplate(grid),
|
||||
});
|
||||
}
|
||||
|
||||
private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid)
|
||||
{
|
||||
var cellName = $"Pts_{taskIndex}";
|
||||
return new FuncDataTemplate<ExamResultRow>((row, _) =>
|
||||
{
|
||||
if (row is null) return new TextBlock();
|
||||
var cell = row.Cells.ElementAtOrDefault(taskIndex);
|
||||
if (cell is null) return new TextBlock();
|
||||
|
||||
var tb = new TextBox
|
||||
{
|
||||
Name = cellName,
|
||||
Text = FormatPoints(cell.Value),
|
||||
BorderThickness = new Thickness(0),
|
||||
Background = Brushes.Transparent,
|
||||
Padding = new Thickness(6, 4),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
ToolTip.SetTip(tb, $"max. {FormatPoints(cell.MaxPoints)} Punkte");
|
||||
UpdateInvalidVisual(tb, cell.IsInvalid);
|
||||
|
||||
cell.PropertyChanged += (_, pe) =>
|
||||
{
|
||||
if (pe.PropertyName == nameof(PointsCell.IsInvalid))
|
||||
UpdateInvalidVisual(tb, cell.IsInvalid);
|
||||
};
|
||||
|
||||
tb.LostFocus += (_, _) => CommitPointsCell(tb, cell);
|
||||
tb.KeyDown += (_, e) => HandleCellKeyDown(e, grid, row, cellName);
|
||||
|
||||
return tb;
|
||||
});
|
||||
}
|
||||
|
||||
private static void CommitPointsCell(TextBox tb, PointsCell cell)
|
||||
{
|
||||
var text = tb.Text?.Trim().Replace(',', '.');
|
||||
double? parsed = string.IsNullOrEmpty(text)
|
||||
? null
|
||||
: double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : cell.Value;
|
||||
cell.TrySetValue(parsed);
|
||||
tb.Text = FormatPoints(cell.Value);
|
||||
}
|
||||
|
||||
private static void UpdateInvalidVisual(TextBox tb, bool invalid) =>
|
||||
tb.Background = invalid ? InvalidBackground : Brushes.Transparent;
|
||||
|
||||
private static string FormatPoints(double? value) =>
|
||||
value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "";
|
||||
|
||||
private IDataTemplate BuildAbsentCellTemplate()
|
||||
{
|
||||
return new FuncDataTemplate<ExamResultRow>((row, _) =>
|
||||
{
|
||||
if (row is null) return new TextBlock();
|
||||
var cb = new CheckBox { IsChecked = row.Absent, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
cb.IsCheckedChanged += (_, _) => row.Absent = cb.IsChecked == true;
|
||||
return cb;
|
||||
});
|
||||
}
|
||||
|
||||
private IDataTemplate BuildCommentCellTemplate(DataGrid grid)
|
||||
{
|
||||
const string cellName = "Comment";
|
||||
return new FuncDataTemplate<ExamResultRow>((row, _) =>
|
||||
{
|
||||
if (row is null) return new TextBlock();
|
||||
var tb = new TextBox
|
||||
{
|
||||
Name = cellName,
|
||||
Text = row.Comment,
|
||||
BorderThickness = new Thickness(0),
|
||||
Background = Brushes.Transparent,
|
||||
Padding = new Thickness(6, 4),
|
||||
};
|
||||
tb.LostFocus += (_, _) => row.Comment = tb.Text ?? "";
|
||||
tb.KeyDown += (_, e) => HandleCellKeyDown(e, grid, row, cellName);
|
||||
return tb;
|
||||
});
|
||||
}
|
||||
|
||||
/// Enter/Pfeil-Hoch/Pfeil-Runter springen zur gleichen Spalte in der Nachbarzeile
|
||||
/// (Tab funktioniert bereits über die normale Fokus-Reihenfolge).
|
||||
private void HandleCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName)
|
||||
{
|
||||
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
|
||||
e.Handled = true;
|
||||
|
||||
if (grid.ItemsSource is not IList items) return;
|
||||
var idx = items.IndexOf(rowItem);
|
||||
if (idx < 0) return;
|
||||
var targetIdx = e.Key == Key.Up ? idx - 1 : idx + 1;
|
||||
if (targetIdx < 0 || targetIdx >= items.Count) return;
|
||||
|
||||
var targetItem = items[targetIdx]!;
|
||||
grid.ScrollIntoView(targetItem, grid.CurrentColumn);
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var targetTb = grid.GetVisualDescendants().OfType<TextBox>()
|
||||
.FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
|
||||
if (targetTb is null) return;
|
||||
targetTb.Focus();
|
||||
targetTb.SelectAll();
|
||||
}, DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -70,6 +70,7 @@
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8"
|
||||
IsVisible="{Binding SelectedExam, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<Button Content="Punkte eingeben" Command="{Binding GradeExamCommand}"/>
|
||||
<Button Content="Bearbeiten" Command="{Binding EditExamCommand}"/>
|
||||
<Button Content="Duplizieren" Command="{Binding DuplicateExamCommand}"/>
|
||||
<SplitButton Content="Status ▸" Command="{Binding AdvanceExamStatusCommand}">
|
||||
@@ -111,6 +112,7 @@
|
||||
</DataGrid.Columns>
|
||||
<DataGrid.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Punkte eingeben" Command="{Binding GradeExamCommand}"/>
|
||||
<MenuItem Header="Bearbeiten" Command="{Binding EditExamCommand}"/>
|
||||
<MenuItem Header="Duplizieren" Command="{Binding DuplicateExamCommand}"/>
|
||||
<MenuItem Header="Status">
|
||||
|
||||
@@ -21,6 +21,7 @@ public partial class GroupDetailView : UserControl
|
||||
vm.OnEditExam = ShowEditExamDialog;
|
||||
vm.OnDuplicateExam = ShowDuplicateExamDialog;
|
||||
vm.OnConfirmDeleteExam = ShowDeleteExamDialog;
|
||||
vm.OnGradeExam = ShowGradeExamDialog;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +76,20 @@ public partial class GroupDetailView : UserControl
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
return owner is not null && await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
private async Task ShowGradeExamDialog(Exam exam)
|
||||
{
|
||||
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
|
||||
|
||||
var dialogVm = new ExamGradingDialogViewModel(
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IEnrollmentRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
exam, vm.Group.Id, vm.Group.SchoolYear);
|
||||
|
||||
var dialog = new ExamGradingDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is not null) await dialog.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,14 +60,19 @@ Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits i
|
||||
(`GradingService.ValidateGradingKey`).
|
||||
|
||||
### 1.4 Punkteeingabe & Korrektur
|
||||
- [ ] **1.4.1** Eingaberaster: Zeilen = Schüler, Spalten = Aufgaben, letzte Spalte Summe + Note.
|
||||
- [x] **1.4.1** Eingaberaster: Zeilen = Schüler, Spalten = Aufgaben, letzte Spalte Summe + Note.
|
||||
Note wird live über `GradingService.CalculateGrade()` berechnet.
|
||||
- [ ] **1.4.2** Tastaturnavigation analog zum Mitarbeit-Dialog: Tab/Enter = nächstes Feld,
|
||||
Pfeiltasten, direkte Zifferneingabe, halbe Punkte (0,5) erlaubt.
|
||||
- [ ] **1.4.3** Kennzeichnung "Abwesend" (`ExamResult.Absent`) — Schüler aus Statistik ausschließen.
|
||||
- [ ] **1.4.4** Kommentarfeld pro Schüler (`ExamResult.Comment`).
|
||||
- [ ] **1.4.5** Validierung: Punkte > Maximalpunkte der Aufgabe wird rot markiert und nicht gespeichert.
|
||||
- [ ] **1.4.6** Autosave nach jeder Zelle (kein expliziter Speichern-Button nötig).
|
||||
Umgesetzt als eigener Dialog `ExamGradingDialog`, erreichbar über "Punkte eingeben"
|
||||
im Klausuren-Tab (Button/Kontextmenü bei ausgewählter Klausur).
|
||||
- [x] **1.4.2** Tastaturnavigation: Tab bewegt sich nativ zur nächsten Zelle, Enter/Pfeil-Hoch/
|
||||
Pfeil-Runter springen zur gleichen Spalte in der Nachbarzeile, direkte Zifferneingabe
|
||||
und halbe Punkte (Komma oder Punkt) erlaubt.
|
||||
- [x] **1.4.3** Kennzeichnung "Abwesend" (`ExamResult.Absent`) per Checkbox — Note zeigt "abwesend"
|
||||
statt einer berechneten Note. Ausschluss aus Statistiken folgt mit 1.5 (noch nicht umgesetzt).
|
||||
- [x] **1.4.4** Kommentarfeld pro Schüler (`ExamResult.Comment`).
|
||||
- [x] **1.4.5** Validierung: Punkte < 0 oder > Maximalpunkte der Aufgabe wird rot markiert und nicht
|
||||
gespeichert (`PointsCell.TrySetValue`).
|
||||
- [x] **1.4.6** Autosave nach jeder Zelle (kein expliziter Speichern-Button nötig).
|
||||
|
||||
### 1.5 Klausurauswertung
|
||||
- [ ] **1.5.1** Notenspiegel (Häufigkeitsverteilung als Balken), Durchschnitt, Median,
|
||||
|
||||
Reference in New Issue
Block a user