Arbeitszeit: wiederkehrende Aufgaben + Auto-Aufgabe bei Klausurkorrektur (6.1.4/6.1.5)

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.
This commit is contained in:
2026-08-16 00:39:18 +02:00
parent f9e398b225
commit 2b4fda7bb3
8 changed files with 259 additions and 9 deletions
@@ -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);
@@ -55,6 +55,22 @@ public static class TaskCategoryDisplay
Enum.GetValues<TaskCategory>().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<TaskRecurrence>().Select(Label).ToArray();
public static TaskRecurrence FromLabel(string? label) =>
Enum.GetValues<TaskRecurrence>().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<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options];
public List<LearningGroup> 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,