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>
186 lines
6.7 KiB
C#
186 lines
6.7 KiB
C#
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();
|
|
}
|