From 2b4fda7bb3d8ab83fe52a3c30dbbbeae39fd5f26 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 16 Aug 2026 00:38:19 +0200 Subject: [PATCH] Arbeitszeit: wiederkehrende Aufgaben + Auto-Aufgabe bei Klausurkorrektur (6.1.4/6.1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Damit ist Kapitel 6 (Arbeitszeit & Aufgaben) vollständig abgeschlossen (bis auf den Export der Auswertung, der an das noch fehlende Kapitel 11 hängt). - WorkTask.Recurrence (None/Weekly/Monthly): beim Abschließen einer wiederkehrenden Aufgabe wird automatisch die nächste Instanz mit verschobenem Fälligkeitsdatum erzeugt. - GroupDetailViewModel.SetExamStatus legt beim ersten Wechsel einer Klausur von "Geplant" auf "Durchgeführt" automatisch eine Korrektur-Aufgabe an. --- LehrerApp.Core/Models/Workload.cs | 3 + .../GroupDetailViewModelTests.cs | 72 +++++++++++++++ .../WorkloadViewModelTests.cs | 91 +++++++++++++++++++ .../ViewModels/Groups/GroupViewModels.cs | 18 +++- .../ViewModels/Workload/WorkloadViewModels.cs | 50 +++++++++- .../Workload/AddEditWorkTaskDialog.axaml | 7 ++ .../Views/Workload/WorkTaskListView.axaml | 3 + TODO.md | 24 +++-- 8 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs index fef15ec..450b7d0 100644 --- a/LehrerApp.Core/Models/Workload.cs +++ b/LehrerApp.Core/Models/Workload.cs @@ -85,6 +85,8 @@ public class WorkTask public int? EstimatedMinutes { get; set; } public WorkTaskStatus Status { get; set; } = WorkTaskStatus.Open; public string? Notes { get; set; } + // Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4). + public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } @@ -103,3 +105,4 @@ public class TimeEntry } public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other } public enum WorkTaskStatus { Open, InProgress, Done } +public enum TaskRecurrence { None, Weekly, Monthly } diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs new file mode 100644 index 0000000..8f40b81 --- /dev/null +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -0,0 +1,72 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class GroupDetailViewModelTests +{ + private static (GroupDetailViewModel Vm, FakeExams Exams, FakeWorkTasks Tasks) Build(LearningGroup group, Exam exam) + { + var exams = new FakeExams([exam]); + var tasks = new FakeWorkTasks(); + var groups = new FakeGroups([group]); + var students = new FakeStudents([]); + var memberships = new FakeMemberships([]); + var subjects = new FakeSubjects([]); + var grades = new FakeGrades(); + + var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks, + new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), + students, memberships, groups, new FakeCompetencyDomains()), + new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), + new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new FakeCompetencyDomains())); + + vm.LoadGroup(group.Id); + vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); + return (vm, exams, tasks); + } + + [Fact] + public void SetExamStatus_PlannedZuConducted_ErstelltKorrekturAufgabe() + { + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Date = new DateOnly(2026, 3, 10) }; + var (vm, _, tasks) = Build(group, exam); + + vm.SetExamStatusCommand.Execute(ExamStatus.Conducted); + + var task = Assert.Single(tasks.GetAll()); + Assert.Equal("Klausur korrigieren: Klausur 1", task.Title); + Assert.Equal(TaskCategory.Correction, task.Category); + Assert.Equal(group.Id, task.GroupId); + Assert.Equal(new DateOnly(2026, 3, 24), task.DueDate); + } + + [Fact] + public void SetExamStatus_ConductedErneutGesetzt_ErstelltKeineZweiteAufgabe() + { + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Date = new DateOnly(2026, 3, 10) }; + var (vm, _, tasks) = Build(group, exam); + + vm.SetExamStatusCommand.Execute(ExamStatus.Conducted); + vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); + vm.SetExamStatusCommand.Execute(ExamStatus.Conducted); + + Assert.Single(tasks.GetAll()); + } + + [Fact] + public void SetExamStatus_DirektAufGraded_ErstelltKeineAufgabe() + { + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" }; + var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Date = new DateOnly(2026, 3, 10) }; + var (vm, _, tasks) = Build(group, exam); + + vm.SetExamStatusCommand.Execute(ExamStatus.Graded); + + Assert.Empty(tasks.GetAll()); + } +} diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs index 7955586..7f3421f 100644 --- a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs @@ -93,6 +93,66 @@ public sealed class WorkTaskListViewModelTests Assert.Equal(WorkTaskStatus.Open, vm.Tasks[0].Model.Status); } + [Fact] + public void CycleStatus_WoechentlicheWiederholungAufErledigt_ErzeugtNaechsteInstanz() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask + { + Title = "Wochenbericht", Status = WorkTaskStatus.InProgress, + Recurrence = TaskRecurrence.Weekly, DueDate = new DateOnly(2026, 3, 10), + Category = TaskCategory.Admin, + }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries()) + { + StatusFilter = WorkTaskListViewModel.AllFilter, + }; + + vm.CycleStatusCommand.Execute(vm.Tasks[0]); // -> Done + + var all = tasks.GetAll(); + Assert.Equal(2, all.Count); + var next = all.Single(t => t.Status == WorkTaskStatus.Open); + Assert.Equal("Wochenbericht", next.Title); + Assert.Equal(new DateOnly(2026, 3, 17), next.DueDate); + Assert.Equal(TaskRecurrence.Weekly, next.Recurrence); + } + + [Fact] + public void CycleStatus_MonatlicheWiederholung_AddiertEinenMonat() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask + { + Title = "Bericht", Status = WorkTaskStatus.InProgress, + Recurrence = TaskRecurrence.Monthly, DueDate = new DateOnly(2026, 3, 10), + }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries()) + { + StatusFilter = WorkTaskListViewModel.AllFilter, + }; + + vm.CycleStatusCommand.Execute(vm.Tasks[0]); // -> Done + + var next = tasks.GetAll().Single(t => t.Status == WorkTaskStatus.Open); + Assert.Equal(new DateOnly(2026, 4, 10), next.DueDate); + } + + [Fact] + public void CycleStatus_OhneWiederholung_ErzeugtKeineNeueAufgabe() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.InProgress }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries()) + { + StatusFilter = WorkTaskListViewModel.AllFilter, + }; + + vm.CycleStatusCommand.Execute(vm.Tasks[0]); // -> Done + + Assert.Single(tasks.GetAll()); + } + [Fact] public void DeleteTask_EntferntAufgabeAusDerListe() { @@ -193,6 +253,37 @@ public sealed class AddEditWorkTaskDialogViewModelTests Assert.Equal(source.Id, vm.Result!.Id); Assert.Equal(WorkTaskStatus.InProgress, vm.Result.Status); } + + [Fact] + public void Save_WiederholungOhneFaelligkeitsdatum_SetztFehler() + { + var vm = new AddEditWorkTaskDialogViewModel(null, []) + { + Title = "T", + SelectedRecurrence = TaskRecurrenceDisplay.Label(TaskRecurrence.Weekly), + }; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEmpty(vm.DueDateError); + } + + [Fact] + public void Save_WiederholungMitFaelligkeitsdatum_UebernimmtRecurrence() + { + var vm = new AddEditWorkTaskDialogViewModel(null, []) + { + Title = "T", + DueDateText = "10.03.2026", + SelectedRecurrence = TaskRecurrenceDisplay.Label(TaskRecurrence.Monthly), + }; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence); + } } public sealed class TimeTrackingViewModelTests diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 064b263..d551b51 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -179,6 +179,7 @@ public partial class GroupDetailViewModel : ObservableObject private readonly ISubjectRepository _subjects; private readonly IExamRepository _exams; private readonly IGradeRepository _grades; + private readonly IWorkTaskRepository _tasks; [ObservableProperty] private LearningGroup? _group; [ObservableProperty] private string _groupTitle = ""; @@ -224,12 +225,12 @@ public partial class GroupDetailViewModel : ObservableObject public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IGroupMembershipRepository memberships, ISubjectRepository subjects, - IExamRepository exams, IGradeRepository grades, + IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks, ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab, PlanningTabViewModel planningTab) { _groups = groups; _students = students; _memberships = memberships; _subjects = subjects; - _exams = exams; _grades = grades; + _exams = exams; _grades = grades; _tasks = tasks; ParticipationTab = participationTab; GradeOverviewTab = gradeOverviewTab; PlanningTab = planningTab; @@ -418,12 +419,25 @@ public partial class GroupDetailViewModel : ObservableObject if (SelectedExam is null) return; var exam = _exams.GetById(SelectedExam.Id); if (exam is null) return; + var previousStatus = exam.Status; exam.Status = status; if (status == ExamStatus.Returned) exam.ReturnedAt ??= DateOnly.FromDateTime(DateTime.Today); else exam.ReturnedAt = null; _exams.Save(exam); + + // 6.1.5: beim erstmaligen Erreichen von "Durchgeführt" automatisch eine Korrektur-Aufgabe anlegen. + if (status == ExamStatus.Conducted && previousStatus == ExamStatus.Planned) + _tasks.Save(new WorkTask + { + Title = $"Klausur korrigieren: {exam.Title}", + Category = TaskCategory.Correction, + GroupId = exam.GroupId, + DueDate = exam.Date.AddDays(14), + Status = WorkTaskStatus.Open, + }); + var id = exam.Id; ReloadExams(); SelectedExam = Exams.FirstOrDefault(e => e.Id == id); diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs index aaeca16..6d11446 100644 --- a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs @@ -55,6 +55,22 @@ public static class TaskCategoryDisplay Enum.GetValues().FirstOrDefault(c => Label(c) == label, TaskCategory.Other); } +public static class TaskRecurrenceDisplay +{ + public static string Label(TaskRecurrence r) => r switch + { + TaskRecurrence.None => "Keine", + TaskRecurrence.Weekly => "Wöchentlich", + TaskRecurrence.Monthly => "Monatlich", + _ => r.ToString(), + }; + + public static string[] Options { get; } = Enum.GetValues().Select(Label).ToArray(); + + public static TaskRecurrence FromLabel(string? label) => + Enum.GetValues().FirstOrDefault(r => Label(r) == label, TaskRecurrence.None); +} + // ── Aufgabenliste (6.1.1, 6.1.3) ───────────────────────────────────────────── public partial class WorkTaskListViewModel : ObservableObject @@ -168,8 +184,28 @@ public partial class WorkTaskListViewModel : ObservableObject private void CycleStatus(WorkTaskListItem? item) { if (item is null) return; - item.Model.Status = WorkTaskStatusDisplay.Next(item.Model.Status); + var newStatus = WorkTaskStatusDisplay.Next(item.Model.Status); + item.Model.Status = newStatus; _tasks.Save(item.Model); + + // Wiederkehrende Aufgabe (6.1.4): beim Abschließen automatisch die nächste Instanz anlegen. + if (newStatus == WorkTaskStatus.Done && item.Model.Recurrence != TaskRecurrence.None + && item.Model.DueDate is { } dueDate) + { + _tasks.Save(new WorkTask + { + Title = item.Model.Title, + Category = item.Model.Category, + GroupId = item.Model.GroupId, + DueDate = item.Model.Recurrence == TaskRecurrence.Weekly + ? dueDate.AddDays(7) : dueDate.AddMonths(1), + EstimatedMinutes = item.Model.EstimatedMinutes, + Recurrence = item.Model.Recurrence, + Notes = item.Model.Notes, + Status = WorkTaskStatus.Open, + }); + } + Refresh(); } @@ -196,6 +232,10 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut public string StatusColorHex => WorkTaskStatusDisplay.ColorHex(Model.Status); public string EstimatedMinutesDisplay => Model.EstimatedMinutes is { } m ? $"{m} min" : ""; + // Wiederkehrende Aufgabe (6.1.4). + public bool IsRecurring => Model.Recurrence != TaskRecurrence.None; + public string RecurrenceLabel => $"🔁 {TaskRecurrenceDisplay.Label(Model.Recurrence)}"; + // Ist-Zeit vs. Schätzung (6.2.4) — nur anzeigen, wenn tatsächlich etwas erfasst wurde. public bool HasActualTime => actualMinutes > 0; public string ActualVsEstimateDisplay => Model.EstimatedMinutes is { } estimate @@ -214,6 +254,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject [ObservableProperty] private LearningGroup? _selectedGroup; [ObservableProperty] private string _dueDateText = ""; [ObservableProperty] private string _estimatedMinutesText = ""; + [ObservableProperty] private string _selectedRecurrence = TaskRecurrenceDisplay.Options[0]; [ObservableProperty] private string _notes = ""; [ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _dueDateError = ""; @@ -221,6 +262,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject public string DialogTitle => _source is null ? "Aufgabe anlegen" : "Aufgabe bearbeiten"; public List CategoryOptions { get; } = [.. TaskCategoryDisplay.Options]; + public List RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options]; public List Groups { get; } public WorkTask? Result { get; private set; } @@ -235,6 +277,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject SelectedGroup = source.GroupId is { } id ? groups.FirstOrDefault(g => g.Id == id) : null; DueDateText = source.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? ""; EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? ""; + SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence); Notes = source.Notes ?? ""; } @@ -254,6 +297,10 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject else dueDate = d; } + var recurrence = TaskRecurrenceDisplay.FromLabel(SelectedRecurrence); + if (recurrence != TaskRecurrence.None && dueDate is null) + { DueDateError = "Fälligkeitsdatum erforderlich, damit die nächste Instanz geplant werden kann."; valid = false; } + int? estimatedMinutes = null; if (!string.IsNullOrWhiteSpace(EstimatedMinutesText)) { @@ -272,6 +319,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject GroupId = SelectedGroup?.Id, DueDate = dueDate, EstimatedMinutes = estimatedMinutes, + Recurrence = recurrence, Status = _source?.Status ?? WorkTaskStatus.Open, Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(), CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow, diff --git a/LehrerApp.Desktop/Views/Workload/AddEditWorkTaskDialog.axaml b/LehrerApp.Desktop/Views/Workload/AddEditWorkTaskDialog.axaml index dd86abe..fb818a0 100644 --- a/LehrerApp.Desktop/Views/Workload/AddEditWorkTaskDialog.axaml +++ b/LehrerApp.Desktop/Views/Workload/AddEditWorkTaskDialog.axaml @@ -54,6 +54,13 @@ + + + + + diff --git a/LehrerApp.Desktop/Views/Workload/WorkTaskListView.axaml b/LehrerApp.Desktop/Views/Workload/WorkTaskListView.axaml index bd332c9..ba2517d 100644 --- a/LehrerApp.Desktop/Views/Workload/WorkTaskListView.axaml +++ b/LehrerApp.Desktop/Views/Workload/WorkTaskListView.axaml @@ -36,6 +36,9 @@ + + diff --git a/TODO.md b/TODO.md index 78ce7df..edd589f 100644 --- a/TODO.md +++ b/TODO.md @@ -780,8 +780,8 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das Modelle `WorkTask` und `TimeEntry` existieren, Repositories ebenfalls. Navigationspunkt "Arbeitszeit" zeigt Aufgabenverwaltung (6.1), Zeiterfassung (6.2) und Auswertung (6.3) als drei Tabs (`WorkloadViewModel`/`WorkloadView`, gleiches Container-Tab-Muster wie -`GroupDetailViewModel`) — nur der Export der Auswertung (6.3.3) ist noch offen, da er an das noch -nicht existierende Kapitel 11 (Export-Infrastruktur) hängt. +`GroupDetailViewModel`). **Kapitel 6 ist damit vollständig abgeschlossen** bis auf den Export der +Auswertung (6.3.3), der am noch nicht existierenden Kapitel 11 (Export-Infrastruktur) hängt. ### 6.1 Aufgabenverwaltung - [x] **6.1.1** Aufgabenliste mit Filter nach Status, Kategorie, Gruppe und Fälligkeit — @@ -796,9 +796,17 @@ nicht existierende Kapitel 11 (Export-Infrastruktur) hängt. - [x] **6.1.3** Status per Klick wechseln (`Open → InProgress → Done → Open`) über eine farbige Status-Kachel je Zeile; "Offene Aufgaben" (alles außer `Done`) ist der Default-Filter, damit Erledigtes automatisch ausgeblendet ist, ohne separate Sichtbarkeits-Logik. -- [ ] **6.1.4** Wiederkehrende Aufgaben (wöchentlich/monatlich). -- [ ] **6.1.5** Automatische Aufgabe "Klausur korrigieren" beim Statuswechsel einer Klausur - auf `Conducted` (Anbindung an 1.1.3). +- [x] **6.1.4** Wiederkehrende Aufgaben (wöchentlich/monatlich) — `WorkTask.Recurrence` + (`TaskRecurrence`: None/Weekly/Monthly). Kein eigenes Serien-/Vorlagen-Modell: Beim + Abschließen (Status → `Done` in 6.1.3) wird einfach eine neue `WorkTask` mit gleichem + Titel/Kategorie/Gruppe und um 7 Tage bzw. 1 Monat verschobenem Fälligkeitsdatum erzeugt — + genau das Verhalten, das ein Lehrer von "wöchentlich wiederkehrend" erwartet, ohne + Hintergrundjob oder Kalenderlogik. Braucht deshalb ein Fälligkeitsdatum als Ausgangspunkt + (im Dialog erzwungen, sobald eine Wiederholung gewählt ist). +- [x] **6.1.5** Automatische Aufgabe "Klausur korrigieren" beim Statuswechsel einer Klausur + auf `Conducted` (Anbindung an 1.1.3) — Hook in `GroupDetailViewModel.SetExamStatus`, feuert + nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten + Klick), Fälligkeit 14 Tage nach dem Klausurtermin, Kategorie `Correction`. ### 6.2 Zeiterfassung - [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie — @@ -1273,4 +1281,8 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe Unklarheit; Desktop bekommt dort perspektivisch Graph/Kompetenz-Verknüpfung/KI-Planung, ein möglicher Companion-Client bleibt bewusst minimal). **→ nächster sinnvoller Schritt: Kapitel 6, 10 oder 11.** -7. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach. +7. ~~**Kapitel 6** (Arbeitszeit & Aufgaben)~~ — vollständig erledigt (6.1 Aufgabenverwaltung + inkl. wiederkehrender Aufgaben, 6.2 Zeiterfassung, 6.3 Auswertung). Offen bleibt nur **6.3.3** + (Export der Auswertung), das am noch fehlenden Kapitel 11 hängt. + **→ nächster sinnvoller Schritt: Kapitel 10 (Sync) oder 11 (Export).** +8. **Kapitel 11** (Export), **10** (Sync) — danach.