diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 7ee0cdb..35b258d 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -270,6 +270,16 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); } +public class FakeWorkTasks : IWorkTaskRepository +{ + private readonly List _all = []; + public void Add(WorkTask t) => _all.Add(t); + public List GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList(); + public List GetAll() => _all.ToList(); + public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); } + public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id); +} + public class FakeReportGrades : IReportGradeRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs new file mode 100644 index 0000000..6487aa9 --- /dev/null +++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs @@ -0,0 +1,195 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Workload; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class WorkTaskListViewModelTests +{ + [Fact] + public void Load_BlendetErledigteAufgabenStandardmaessigAus() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "Offen", Status = WorkTaskStatus.Open }); + tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])); + + Assert.Single(vm.Tasks); + Assert.Equal("Offen", vm.Tasks[0].Title); + } + + [Fact] + public void StatusFilter_Alle_ZeigtAuchErledigteAufgaben() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])) + { + StatusFilter = WorkTaskListViewModel.AllFilter, + }; + + Assert.Single(vm.Tasks); + } + + [Fact] + public void CategoryFilter_FiltertNachKategorie() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "Korrektur", Category = TaskCategory.Correction }); + tasks.Add(new WorkTask { Title = "Vorbereitung", Category = TaskCategory.Preparation }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])) + { + CategoryFilter = TaskCategoryDisplay.Label(TaskCategory.Correction), + }; + + Assert.Single(vm.Tasks); + Assert.Equal("Korrektur", vm.Tasks[0].Title); + } + + [Fact] + public void GroupFilter_OhneGruppe_ZeigtNurAufgabenOhneGroupId() + { + var group = new LearningGroup { Name = "8a" }; + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "Mit Gruppe", GroupId = group.Id }); + tasks.Add(new WorkTask { Title = "Ohne Gruppe", GroupId = null }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([group])) + { + GroupFilter = WorkTaskListViewModel.NoGroupFilter, + }; + + Assert.Single(vm.Tasks); + Assert.Equal("Ohne Gruppe", vm.Tasks[0].Title); + } + + [Fact] + public void CycleStatus_WechseltVonOffenNachInBearbeitung() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Open }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])) + { + StatusFilter = WorkTaskListViewModel.AllFilter, + }; + + vm.CycleStatusCommand.Execute(vm.Tasks[0]); + + Assert.Equal(WorkTaskStatus.InProgress, vm.Tasks[0].Model.Status); + } + + [Fact] + public void CycleStatus_VonErledigtSpringtZurueckZuOffen() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Done }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])) + { + StatusFilter = WorkTaskListViewModel.AllFilter, + }; + + vm.CycleStatusCommand.Execute(vm.Tasks[0]); + + Assert.Equal(WorkTaskStatus.Open, vm.Tasks[0].Model.Status); + } + + [Fact] + public void DeleteTask_EntferntAufgabeAusDerListe() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "T" }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])); + + vm.DeleteTaskCommand.Execute(vm.Tasks[0]); + + Assert.Empty(vm.Tasks); + } + + [Fact] + public async Task AddTask_RuftOnEditTaskAufUndSpeichertErgebnis() + { + var tasks = new FakeWorkTasks(); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])); + vm.OnEditTask = _ => Task.FromResult(new WorkTask { Title = "Neu" }); + + await vm.AddTaskCommand.ExecuteAsync(null); + + Assert.Single(vm.Tasks); + Assert.Equal("Neu", vm.Tasks[0].Title); + } + + [Fact] + public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde() + { + var tasks = new FakeWorkTasks(); + tasks.Add(new WorkTask { Title = "Ohne Frist", DueDate = null }); + tasks.Add(new WorkTask { Title = "Spät fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(10)) }); + tasks.Add(new WorkTask { Title = "Bald fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(1)) }); + var vm = new WorkTaskListViewModel(tasks, new FakeGroups([])); + + Assert.Equal(["Bald fällig", "Spät fällig", "Ohne Frist"], vm.Tasks.Select(t => t.Title)); + } +} + +public sealed class AddEditWorkTaskDialogViewModelTests +{ + [Fact] + public void Save_OhneTitel_SetztFehlerUndErzeugtKeinResult() + { + var vm = new AddEditWorkTaskDialogViewModel(null, []); + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEmpty(vm.TitleError); + } + + [Fact] + public void Save_MitGueltigenDaten_ErzeugtWorkTask() + { + var group = new LearningGroup { Name = "8a" }; + var vm = new AddEditWorkTaskDialogViewModel(null, [group]) + { + Title = "Klausur korrigieren", + SelectedCategory = TaskCategoryDisplay.Label(TaskCategory.Correction), + SelectedGroup = group, + DueDateText = "24.12.2026", + EstimatedMinutesText = "90", + }; + + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal("Klausur korrigieren", vm.Result!.Title); + Assert.Equal(TaskCategory.Correction, vm.Result.Category); + Assert.Equal(group.Id, vm.Result.GroupId); + Assert.Equal(new DateOnly(2026, 12, 24), vm.Result.DueDate); + Assert.Equal(90, vm.Result.EstimatedMinutes); + } + + [Fact] + public void Save_MitUngueltigemDatum_SetztFehler() + { + var vm = new AddEditWorkTaskDialogViewModel(null, []) + { + Title = "T", + DueDateText = "nicht-ein-datum", + }; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.NotEmpty(vm.DueDateError); + } + + [Fact] + public void Save_BestehendeAufgabe_BehaeltIdUndStatus() + { + var source = new WorkTask { Title = "Alt", Status = WorkTaskStatus.InProgress }; + var vm = new AddEditWorkTaskDialogViewModel(source, []) { Title = "Neu" }; + + vm.SaveCommand.Execute(null); + + Assert.Equal(source.Id, vm.Result!.Id); + Assert.Equal(WorkTaskStatus.InProgress, vm.Result.Status); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 4c53603..f573158 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -8,6 +8,7 @@ using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Desktop.ViewModels.Workload; using LehrerApp.Sync; using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Models; @@ -187,6 +188,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Transient: neue Instanz pro Navigation (für Detailseiten) services.AddTransient(); diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index c9ee08f..2655127 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,7 @@ using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Desktop.ViewModels.Workload; using Microsoft.Extensions.DependencyInjection; using System.Collections.ObjectModel; @@ -66,7 +67,7 @@ public partial class MainWindowViewModel : ObservableObject NavItem.Students => _services.GetRequiredService(), NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" }, NavItem.Planner => GetTimetable(), - NavItem.Workload => new PlaceholderViewModel { Title = "Arbeitszeit", Icon = "⏱" }, + NavItem.Workload => GetWorkload(), NavItem.Settings => _services.GetRequiredService(), _ => CurrentPage, }; @@ -88,6 +89,13 @@ public partial class MainWindowViewModel : ObservableObject return timetable; } + private WorkTaskListViewModel GetWorkload() + { + var workload = _services.GetRequiredService(); + workload.Load(); + return workload; + } + public void NavigateToGroupDetail(Guid groupId, int initialTab = 0) { ActiveNavItem = NavItem.Groups; diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs new file mode 100644 index 0000000..9cdd8bd --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs @@ -0,0 +1,266 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Workload; + +// ── Anzeige-Hilfen für die Enums (6.1) ────────────────────────────────────── + +public static class WorkTaskStatusDisplay +{ + public static string Label(WorkTaskStatus s) => s switch + { + WorkTaskStatus.Open => "Offen", + WorkTaskStatus.InProgress => "In Bearbeitung", + WorkTaskStatus.Done => "Erledigt", + _ => s.ToString(), + }; + + public static string ColorHex(WorkTaskStatus s) => s switch + { + WorkTaskStatus.Open => "#9E9E9E", + WorkTaskStatus.InProgress => "#1976D2", + WorkTaskStatus.Done => "#43A047", + _ => "#9E9E9E", + }; + + // Klick auf die Status-Kachel wechselt reihum (6.1.3). + public static WorkTaskStatus Next(WorkTaskStatus s) => s switch + { + WorkTaskStatus.Open => WorkTaskStatus.InProgress, + WorkTaskStatus.InProgress => WorkTaskStatus.Done, + _ => WorkTaskStatus.Open, + }; +} + +public static class TaskCategoryDisplay +{ + public static string Label(TaskCategory c) => c switch + { + TaskCategory.Correction => "Korrektur", + TaskCategory.Preparation => "Vorbereitung", + TaskCategory.Admin => "Verwaltung", + TaskCategory.Meeting => "Besprechung", + TaskCategory.Other => "Sonstiges", + _ => c.ToString(), + }; + + public static string[] Options { get; } = Enum.GetValues().Select(Label).ToArray(); + + public static TaskCategory FromLabel(string? label) => + Enum.GetValues().FirstOrDefault(c => Label(c) == label, TaskCategory.Other); +} + +// ── Aufgabenliste (6.1.1, 6.1.3) ───────────────────────────────────────────── + +public partial class WorkTaskListViewModel : ObservableObject +{ + private readonly IWorkTaskRepository _tasks; + private readonly IGroupRepository _groups; + + public const string AllFilter = "Alle"; + public const string ActiveFilter = "Offene Aufgaben"; + public const string DoneFilter = "Erledigt"; + public const string NoGroupFilter = "Ohne Gruppe"; + + [ObservableProperty] private string _statusFilter = ActiveFilter; + [ObservableProperty] private string _categoryFilter = AllFilter; + [ObservableProperty] private string _groupFilter = AllFilter; + + public List StatusFilterOptions { get; } = [ActiveFilter, AllFilter, DoneFilter]; + public List CategoryFilterOptions { get; } = [AllFilter, .. TaskCategoryDisplay.Options]; + public ObservableCollection GroupFilterOptions { get; } = [AllFilter, NoGroupFilter]; + + public ObservableCollection Tasks { get; } = []; + public string CountSummary => $"{Tasks.Count} Aufgabe(n)"; + + public Func>? OnEditTask { get; set; } + + private Dictionary _groupNames = []; + + public WorkTaskListViewModel(IWorkTaskRepository tasks, IGroupRepository groups) + { + _tasks = tasks; + _groups = groups; + Load(); + } + + partial void OnStatusFilterChanged(string value) => Refresh(); + partial void OnCategoryFilterChanged(string value) => Refresh(); + partial void OnGroupFilterChanged(string value) => Refresh(); + + public void Load() + { + var groups = _groups.GetAll(includeInactive: true); + _groupNames = groups.ToDictionary(g => g.Id, g => g.Name); + + GroupFilterOptions.Clear(); + GroupFilterOptions.Add(AllFilter); + GroupFilterOptions.Add(NoGroupFilter); + foreach (var name in groups.Select(g => g.Name).OrderBy(n => n)) + GroupFilterOptions.Add(name); + + Refresh(); + } + + private void Refresh() + { + Tasks.Clear(); + var all = _tasks.GetAll().AsEnumerable(); + + all = StatusFilter switch + { + ActiveFilter => all.Where(t => t.Status != WorkTaskStatus.Done), + DoneFilter => all.Where(t => t.Status == WorkTaskStatus.Done), + _ => all, + }; + + if (CategoryFilter != AllFilter) + { + var category = TaskCategoryDisplay.FromLabel(CategoryFilter); + all = all.Where(t => t.Category == category); + } + + if (GroupFilter == NoGroupFilter) + all = all.Where(t => t.GroupId is null); + else if (GroupFilter != AllFilter) + all = all.Where(t => t.GroupId.HasValue && _groupNames.GetValueOrDefault(t.GroupId.Value) == GroupFilter); + + // Fälligkeit: fällige zuerst, unbefristete ans Ende (6.1.1). + foreach (var t in all.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).ThenBy(t => t.Title)) + Tasks.Add(new WorkTaskListItem(t, _groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty))); + + OnPropertyChanged(nameof(CountSummary)); + } + + [RelayCommand] + private async Task AddTask() + { + if (OnEditTask is null) return; + var result = await OnEditTask(null); + if (result is null) return; + _tasks.Save(result); + Refresh(); + } + + [RelayCommand] + private async Task EditTask(WorkTaskListItem? item) + { + if (item is null || OnEditTask is null) return; + var result = await OnEditTask(item.Model); + if (result is null) return; + _tasks.Save(result); + Refresh(); + } + + [RelayCommand] + private void CycleStatus(WorkTaskListItem? item) + { + if (item is null) return; + item.Model.Status = WorkTaskStatusDisplay.Next(item.Model.Status); + _tasks.Save(item.Model); + Refresh(); + } + + [RelayCommand] + private void DeleteTask(WorkTaskListItem? item) + { + if (item is null) return; + _tasks.Delete(item.Model.Id); + Refresh(); + } +} + +public class WorkTaskListItem(WorkTask model, string? groupName) +{ + public WorkTask Model { get; } = model; + public string Title => Model.Title; + public string CategoryLabel => TaskCategoryDisplay.Label(Model.Category); + public string GroupName => groupName ?? ""; + public string DueDateDisplay => Model.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? ""; + public bool IsOverdue => Model.DueDate is { } d && d < DateOnly.FromDateTime(DateTime.Today) + && Model.Status != WorkTaskStatus.Done; + public string DueDateColorHex => IsOverdue ? "#D32F2F" : "#9E9E9E"; + public string StatusLabel => WorkTaskStatusDisplay.Label(Model.Status); + public string StatusColorHex => WorkTaskStatusDisplay.ColorHex(Model.Status); + public string EstimatedMinutesDisplay => Model.EstimatedMinutes is { } m ? $"{m} min" : ""; +} + +// ── Dialog: Aufgabe anlegen/bearbeiten (6.1.2) ─────────────────────────────── + +public partial class AddEditWorkTaskDialogViewModel : ObservableObject +{ + private readonly WorkTask? _source; + + [ObservableProperty] private string _title = ""; + [ObservableProperty] private string _selectedCategory = TaskCategoryDisplay.Options[0]; + [ObservableProperty] private LearningGroup? _selectedGroup; + [ObservableProperty] private string _dueDateText = ""; + [ObservableProperty] private string _estimatedMinutesText = ""; + [ObservableProperty] private string _notes = ""; + [ObservableProperty] private string _titleError = ""; + [ObservableProperty] private string _dueDateError = ""; + [ObservableProperty] private string _estimatedMinutesError = ""; + + public string DialogTitle => _source is null ? "Aufgabe anlegen" : "Aufgabe bearbeiten"; + public List CategoryOptions { get; } = [.. TaskCategoryDisplay.Options]; + public List Groups { get; } + public WorkTask? Result { get; private set; } + + public AddEditWorkTaskDialogViewModel(WorkTask? source, List groups) + { + _source = source; + Groups = groups; + if (source is null) return; + + Title = source.Title; + SelectedCategory = TaskCategoryDisplay.Label(source.Category); + 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) ?? ""; + Notes = source.Notes ?? ""; + } + + [RelayCommand] + private void Save() + { + TitleError = ""; DueDateError = ""; EstimatedMinutesError = ""; + var valid = true; + + if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } + + DateOnly? dueDate = null; + if (!string.IsNullOrWhiteSpace(DueDateText)) + { + if (!DateOnly.TryParseExact(DueDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var d)) + { DueDateError = "Format TT.MM.JJJJ."; valid = false; } + else dueDate = d; + } + + int? estimatedMinutes = null; + if (!string.IsNullOrWhiteSpace(EstimatedMinutesText)) + { + if (!int.TryParse(EstimatedMinutesText, out var m) || m <= 0) + { EstimatedMinutesError = "Ganze Zahl > 0 erwartet."; valid = false; } + else estimatedMinutes = m; + } + + if (!valid) return; + + Result = new WorkTask + { + Id = _source?.Id ?? Guid.NewGuid(), + Title = Title.Trim(), + Category = TaskCategoryDisplay.FromLabel(SelectedCategory), + GroupId = SelectedGroup?.Id, + DueDate = dueDate, + EstimatedMinutes = estimatedMinutes, + Status = _source?.Status ?? WorkTaskStatus.Open, + Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(), + CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow, + }; + } +} diff --git a/LehrerApp.Desktop/Views/MainWindow.axaml b/LehrerApp.Desktop/Views/MainWindow.axaml index c2d44dc..308ddfd 100644 --- a/LehrerApp.Desktop/Views/MainWindow.axaml +++ b/LehrerApp.Desktop/Views/MainWindow.axaml @@ -11,6 +11,8 @@ xmlns:vmset="clr-namespace:LehrerApp.Desktop.ViewModels.Settings" xmlns:vp="clr-namespace:LehrerApp.Desktop.Views.Planning" xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning" + xmlns:vw="clr-namespace:LehrerApp.Desktop.Views.Workload" + xmlns:vmw="clr-namespace:LehrerApp.Desktop.ViewModels.Workload" xmlns:svc="clr-namespace:LehrerApp.Desktop.Services" x:Class="LehrerApp.Desktop.Views.MainWindow" x:DataType="vm:MainWindowViewModel" @@ -53,6 +55,9 @@ + + + diff --git a/LehrerApp.Desktop/Views/Workload/AddEditWorkTaskDialog.axaml b/LehrerApp.Desktop/Views/Workload/AddEditWorkTaskDialog.axaml new file mode 100644 index 0000000..dd86abe --- /dev/null +++ b/LehrerApp.Desktop/Views/Workload/AddEditWorkTaskDialog.axaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +