Nachreichungen direkt im Unterrichtsmodus erfassen
CI / build-and-test (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 23:38:45 +02:00
1 parent f0462bd2a1
commit 6c0a2dbd2e
6 files changed
+214 -6

No files matched your search

@@ -67,4 +67,94 @@ public sealed class TeachingModeOutstandingHomeworkViewModelTests
vm.Refresh();
Assert.Equal(amelia.FullName, Assert.Single(vm.Rows).StudentName);
}
[Theory]
[InlineData(HomeworkStatus.MissingOpen, "submitted", HomeworkStatus.SubmittedLate)]
[InlineData(HomeworkStatus.MissingOpen, "final", HomeworkStatus.MissingOverdue)]
[InlineData(HomeworkStatus.MissingOpen, "partial", HomeworkStatus.PartialMissingOverdue)]
[InlineData(HomeworkStatus.MissingOverdue, "submitted", HomeworkStatus.SubmittedLate)]
[InlineData(HomeworkStatus.PartiallyCompleted, "submitted", HomeworkStatus.PartialSubmittedLate)]
[InlineData(HomeworkStatus.PartiallyCompleted, "final", HomeworkStatus.PartialMissingOverdue)]
[InlineData(HomeworkStatus.PartialMissingOverdue, "submitted", HomeworkStatus.PartialSubmittedLate)]
public void Zeilenaktionen_SetzenPassendenEndstatus(HomeworkStatus start, string action, HomeworkStatus expected)
{
var (vm, entries, entry) = CreateSingle(start);
vm.Refresh();
var row = Assert.Single(vm.Rows);
var command = action switch
{
"submitted" => row.MarkSubmittedCommand,
"final" => row.MarkFinalCommand,
_ => row.MarkPartialCommand,
};
command.Execute(null);
var saved = entries.GetBySessionAndStudent(entry.SessionId, entry.StudentId)!;
Assert.Equal(expected, saved.Homework);
Assert.Equal(HomeworkDisplay.CountsAsMissing(expected), saved.HomeworkMissing);
Assert.Equal(HomeworkDisplay.CountsAsMissing(expected), vm.Rows.Count == 1);
}
[Fact]
public void Zeilenaktionen_BietenNurSinnvolleOptionenAn()
{
var (vm, _, _) = CreateSingle(HomeworkStatus.MissingOverdue);
vm.Refresh();
var overdue = Assert.Single(vm.Rows);
Assert.False(overdue.CanMarkFinal);
Assert.True(overdue.CanMarkPartial);
(vm, _, _) = CreateSingle(HomeworkStatus.PartiallyCompleted);
vm.Refresh();
var partial = Assert.Single(vm.Rows);
Assert.True(partial.CanMarkFinal);
Assert.False(partial.CanMarkPartial);
(vm, _, _) = CreateSingle(HomeworkStatus.MissingOpen, isReadOnly: true);
vm.Refresh();
Assert.False(Assert.Single(vm.Rows).CanEdit);
}
[Fact]
public void Undo_StelltVorherigenStatusWiederHer()
{
var (vm, entries, entry) = CreateSingle(HomeworkStatus.MissingOpen);
var changed = 0;
vm.OnHomeworkChanged = () => changed++;
vm.Refresh();
Assert.False(vm.UndoCommand.CanExecute(null));
vm.Rows[0].MarkSubmittedCommand.Execute(null);
Assert.Empty(vm.Rows);
Assert.NotNull(vm.LastChangeLabel);
Assert.True(vm.UndoCommand.CanExecute(null));
vm.UndoCommand.Execute(null);
var saved = entries.GetBySessionAndStudent(entry.SessionId, entry.StudentId)!;
Assert.Equal(HomeworkStatus.MissingOpen, saved.Homework);
Assert.True(saved.HomeworkMissing);
Assert.Single(vm.Rows);
Assert.Null(vm.LastChangeLabel);
Assert.False(vm.UndoCommand.CanExecute(null));
Assert.Equal(2, changed);
}
private static (TeachingModeOutstandingHomeworkViewModel Vm, FakeEntries Entries, ParticipationEntry Entry)
CreateSingle(HomeworkStatus status, bool isReadOnly = false)
{
var date = new DateOnly(2026, 9, 25);
var group = new LearningGroup { Name = "10c Chemie" };
var student = new Student { FirstName = "Ben", LastName = "Muster" };
var session = new ParticipationSession { GroupId = group.Id, Date = date.AddDays(-5) };
var entries = new FakeEntries();
var entry = new ParticipationEntry { SessionId = session.Id, StudentId = student.Id,
Homework = status, HomeworkMissing = true };
entries.Add(entry);
var vm = new TeachingModeOutstandingHomeworkViewModel(group.Id, date,
new FakeStudents([student]),
new FakeMemberships([new GroupMembership { StudentId = student.Id, GroupId = group.Id }]),
new FakeSessions([session]), entries, isReadOnly);
return (vm, entries, entry);
}
}
@@ -7,22 +7,67 @@ using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public sealed record OutstandingHomeworkRow(string StudentName, DateOnly Date, HomeworkStatus Status)
/// <summary>Eine offene Nachreichung. Die Aktionen setzen nur die Endzustände, die beim Nachreichen
/// sinnvoll sind: nachgereicht, endgültig nicht nachgereicht oder (bei ganz fehlender Hausaufgabe)
/// teilweise angefertigt mit endgültig fehlendem Rest.</summary>
public sealed class OutstandingHomeworkRow
{
public OutstandingHomeworkRow(Guid sessionId, Guid studentId, string studentName, DateOnly date,
HomeworkStatus status, bool canEdit, Action<OutstandingHomeworkRow, HomeworkStatus> apply)
{
SessionId = sessionId;
StudentId = studentId;
StudentName = studentName;
Date = date;
Status = status;
CanEdit = canEdit;
MarkSubmittedCommand = new RelayCommand(() => apply(this, SubmittedStatus));
MarkFinalCommand = new RelayCommand(() => apply(this, FinalStatus));
MarkPartialCommand = new RelayCommand(() => apply(this, HomeworkStatus.PartialMissingOverdue));
}
public Guid SessionId { get; }
public Guid StudentId { get; }
public string StudentName { get; }
public DateOnly Date { get; }
public HomeworkStatus Status { get; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StatusLabel => HomeworkDisplay.Label(Status);
private bool IsPartial => Status is HomeworkStatus.PartiallyCompleted or HomeworkStatus.PartialMissingOverdue;
private bool IsFinal => Status is HomeworkStatus.MissingOverdue or HomeworkStatus.PartialMissingOverdue;
public HomeworkStatus SubmittedStatus =>
IsPartial ? HomeworkStatus.PartialSubmittedLate : HomeworkStatus.SubmittedLate;
public HomeworkStatus FinalStatus =>
IsPartial ? HomeworkStatus.PartialMissingOverdue : HomeworkStatus.MissingOverdue;
public string SubmittedLabel => IsPartial ? "◕ Rest nachgereicht" : "↺ Nachgereicht";
public string FinalLabel => IsPartial ? "✕ Rest endgültig nicht" : "✕ Endgültig nicht";
public bool CanEdit { get; }
public bool CanMarkFinal => CanEdit && !IsFinal;
public bool CanMarkPartial => CanEdit && !IsPartial;
public IRelayCommand MarkSubmittedCommand { get; }
public IRelayCommand MarkFinalCommand { get; }
public IRelayCommand MarkPartialCommand { get; }
}
/// <summary>Jüngste noch fehlende Hausaufgaben der aktuellen Lerngruppe. Liest bei jedem Öffnen
/// und auf Wunsch erneut, damit die Anwesenheit der laufenden Stunde berücksichtigt wird.</summary>
/// und auf Wunsch erneut, damit die Anwesenheit der laufenden Stunde berücksichtigt wird.
/// Nachreichungen lassen sich direkt in der Liste abschließen, ohne die alte Sitzung zu öffnen;
/// die jeweils letzte Änderung kann rückgängig gemacht werden.</summary>
public partial class TeachingModeOutstandingHomeworkViewModel(
Guid groupId, DateOnly lessonDate,
IStudentRepository students, IGroupMembershipRepository memberships,
IParticipationSessionRepository sessions, IParticipationRepository participation) : ObservableObject
IParticipationSessionRepository sessions, IParticipationRepository participation,
bool isReadOnly = false) : ObservableObject
{
private const int LookbackDays = 120;
private const int MaxRows = 20;
private Guid? _currentSessionId;
private (Guid EntryId, Guid SessionId, Guid StudentId, HomeworkStatus? Homework, bool Missing)? _undo;
public ObservableCollection<OutstandingHomeworkRow> Rows { get; } = [];
public string Header => $"Offene Nachreichungen ({Rows.Count})";
@@ -30,6 +75,10 @@ public partial class TeachingModeOutstandingHomeworkViewModel(
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _includeAbsentStudents;
[ObservableProperty] private string? _lastChangeLabel;
/// <summary>Wird nach jeder Statusänderung aufgerufen, damit andere Ansichten neu laden können.</summary>
public Action? OnHomeworkChanged { get; set; }
partial void OnIsExpandedChanged(bool value)
{
@@ -63,15 +112,55 @@ public partial class TeachingModeOutstandingHomeworkViewModel(
.Select(entry => (session.Date, Entry: entry)))
.Where(x => roster.ContainsKey(x.Entry.StudentId) &&
(IncludeAbsentStudents || !absentIds.Contains(x.Entry.StudentId)))
.Select(x => (x.Date, x.Entry.StudentId, Status: HomeworkDisplay.Effective(x.Entry)))
.Select(x => (x.Date, x.Entry.SessionId, x.Entry.StudentId, Status: HomeworkDisplay.Effective(x.Entry)))
.Where(x => x.Status is { } status && HomeworkDisplay.CountsAsMissing(status))
.OrderByDescending(x => x.Date)
.ThenBy(x => roster[x.StudentId].FullName)
.Take(MaxRows);
foreach (var row in rows)
Rows.Add(new OutstandingHomeworkRow(roster[row.StudentId].FullName, row.Date, row.Status!.Value));
Rows.Add(new OutstandingHomeworkRow(row.SessionId, row.StudentId, roster[row.StudentId].FullName,
row.Date, row.Status!.Value, !isReadOnly, Apply));
OnPropertyChanged(nameof(Header));
OnPropertyChanged(nameof(HasRows));
}
public bool CanUndo => _undo is not null;
public void Apply(OutstandingHomeworkRow row, HomeworkStatus status)
{
if (isReadOnly) return;
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId);
if (entry is null) return;
_undo = (entry.Id, entry.SessionId, entry.StudentId, entry.Homework, entry.HomeworkMissing);
entry.Homework = status;
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(status);
entry.UpdatedAt = DateTime.UtcNow;
participation.Save(entry);
LastChangeLabel = $"{row.StudentName} ({row.DateLabel}): {HomeworkDisplay.Label(status)}";
OnPropertyChanged(nameof(CanUndo));
UndoCommand.NotifyCanExecuteChanged();
Refresh();
OnHomeworkChanged?.Invoke();
}
[RelayCommand(CanExecute = nameof(CanUndo))]
private void Undo()
{
if (_undo is not { } undo) return;
_undo = null;
var entry = participation.GetBySessionAndStudent(undo.SessionId, undo.StudentId);
if (entry is not null && entry.Id == undo.EntryId)
{
entry.Homework = undo.Homework;
entry.HomeworkMissing = undo.Missing;
entry.UpdatedAt = DateTime.UtcNow;
participation.Save(entry);
}
LastChangeLabel = null;
OnPropertyChanged(nameof(CanUndo));
UndoCommand.NotifyCanExecuteChanged();
Refresh();
OnHomeworkChanged?.Invoke();
}
}
@@ -60,6 +60,8 @@ public class TeachingModeViewModel
OutstandingHomework?.SetCurrentSessionId(SeatingPlan.SelectedSession?.Id);
OutstandingHomework?.Refresh();
SeatingPlan.OnAssessmentChanged = () => OutstandingHomework?.Refresh();
if (OutstandingHomework is not null)
OutstandingHomework.OnHomeworkChanged = Participation.RefreshCurrentGrid;
SeatingPlan.PropertyChanged += (_, args) =>
{
if (args.PropertyName != nameof(SeatingPlan.SelectedSession)) return;
@@ -168,6 +168,12 @@
IsChecked="{Binding OutstandingHomework.IncludeAbsentStudents}" FontSize="12"/>
<Button Content="Aktualisieren" Command="{Binding OutstandingHomework.RefreshCommand}"
HorizontalAlignment="Left" FontSize="11" Padding="6,4"/>
<Grid ColumnDefinitions="*,Auto" IsVisible="{Binding OutstandingHomework.LastChangeLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Grid.Column="0" Text="{Binding OutstandingHomework.LastChangeLabel}"
FontSize="11" Opacity="0.75" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Rückgängig" Command="{Binding OutstandingHomework.UndoCommand}"
FontSize="11" Padding="6,2" Margin="6,0,0,0" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="Keine offenen Nachreichungen für die angezeigten Schüler."
IsVisible="{Binding !OutstandingHomework.HasRows}" FontSize="12" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding OutstandingHomework.Rows}">
@@ -180,6 +186,19 @@
<TextBlock FontSize="11" Opacity="0.75" TextWrapping="Wrap">
<Run Text="{Binding DateLabel}"/><Run Text=" · "/><Run Text="{Binding StatusLabel}"/>
</TextBlock>
<WrapPanel IsVisible="{Binding CanEdit}" Margin="0,3,0,0">
<Button Content="{Binding SubmittedLabel}" Command="{Binding MarkSubmittedCommand}"
FontSize="11" Padding="6,2" Margin="0,0,4,4"
ToolTip.Tip="Hausaufgabe wurde nachgereicht"/>
<Button Content="◒ Teilweise" Command="{Binding MarkPartialCommand}"
IsVisible="{Binding CanMarkPartial}"
FontSize="11" Padding="6,2" Margin="0,0,4,4"
ToolTip.Tip="Teilweise angefertigt – der Rest wird nicht mehr nachgereicht"/>
<Button Content="{Binding FinalLabel}" Command="{Binding MarkFinalCommand}"
IsVisible="{Binding CanMarkFinal}"
FontSize="11" Padding="6,2" Margin="0,0,4,4"
ToolTip.Tip="Wird endgültig nicht mehr nachgereicht"/>
</WrapPanel>
</StackPanel>
</Border>
</DataTemplate>
@@ -31,7 +31,8 @@ public partial class TeachingModeWindow : Window
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>());
App.Services.GetRequiredService<IParticipationRepository>(),
isReadOnly: !group.IsActive);
var window = new TeachingModeWindow
{
DataContext = new TeachingModeViewModel(lesson, group,
+7
View File
@@ -2176,6 +2176,13 @@ Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
erfassten Anwesenheit in der aktuellen Sitzung sind standardmäßig ausgeblendet und können über
eine Checkbox eingeblendet werden. Das Panel lädt beim Öffnen und per Aktualisieren-Button neu;
nach dem Schnelldialog für Anwesenheit/Hausaufgaben ebenfalls.
- Nutzer-Feedback: Nachreichungen ließen sich im Panel nur ansehen, nicht erfassen — dafür musste
man den Unterrichtsmodus verlassen und die alte Sitzung öffnen. Jede Zeile hat jetzt direkte
Aktionen, beschränkt auf die beim Nachreichen sinnvollen Endzustände: "Nachgereicht"
(`SubmittedLate` bzw. `PartialSubmittedLate` bei Teil-Hausaufgaben), "Endgültig nicht"
(`MissingOverdue`/`PartialMissingOverdue`, nur solange noch offen) und bei ganz fehlender
Hausaufgabe "Teilweise" (`PartialMissingOverdue`). Die jeweils letzte Änderung lässt sich über
"Rückgängig" zurücknehmen; bei archivierten Gruppen sind die Aktionen ausgeblendet.
- Regressionstests prüfen die 14-Tage-Grenze, unveränderte andere Stati und archivierte Gruppen
sowie die Panel-Filterung nach aktueller Anwesenheit, Mitgliedschaft und Nachreichstatus.