diff --git a/LehrerApp.Desktop.Tests/TeachingModeOutstandingHomeworkViewModelTests.cs b/LehrerApp.Desktop.Tests/TeachingModeOutstandingHomeworkViewModelTests.cs index b495091..1f2ed88 100644 --- a/LehrerApp.Desktop.Tests/TeachingModeOutstandingHomeworkViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TeachingModeOutstandingHomeworkViewModelTests.cs @@ -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); + } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeOutstandingHomeworkViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeOutstandingHomeworkViewModel.cs index c5573be..1808328 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeOutstandingHomeworkViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeOutstandingHomeworkViewModel.cs @@ -7,22 +7,67 @@ using LehrerApp.Core.Services; namespace LehrerApp.Desktop.ViewModels.Groups; -public sealed record OutstandingHomeworkRow(string StudentName, DateOnly Date, HomeworkStatus Status) +/// 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. +public sealed class OutstandingHomeworkRow { + public OutstandingHomeworkRow(Guid sessionId, Guid studentId, string studentName, DateOnly date, + HomeworkStatus status, bool canEdit, Action 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; } } /// 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. +/// 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. 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 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; + + /// Wird nach jeder Statusänderung aufgerufen, damit andere Ansichten neu laden können. + 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(); + } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs index f2b0ec6..0e872d9 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs @@ -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; diff --git a/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml b/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml index 1af5afd..8ff6ede 100644 --- a/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml +++ b/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml @@ -168,6 +168,12 @@ IsChecked="{Binding OutstandingHomework.IncludeAbsentStudents}" FontSize="12"/>