diff --git a/LehrerApp.Core/Services/WorkloadSettingsService.cs b/LehrerApp.Core/Services/WorkloadSettingsService.cs new file mode 100644 index 0000000..e129c53 --- /dev/null +++ b/LehrerApp.Core/Services/WorkloadSettingsService.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace LehrerApp.Core.Services; + +internal class WorkloadSettingsConfig +{ + public double RequiredWeeklyHours { get; set; } +} + +/// Pflichtstundenzahl pro Woche für den Ist-Soll-Abgleich in der Arbeitszeitauswertung (6.3.2). +public class WorkloadSettingsService +{ + private readonly string _configPath; + private WorkloadSettingsConfig _config; + + public double RequiredWeeklyHours => _config.RequiredWeeklyHours; + + public WorkloadSettingsService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "workloadsettings.json"); + _config = Load(); + } + + public void SetRequiredWeeklyHours(double hours) + { + _config.RequiredWeeklyHours = hours; + File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); + } + + private WorkloadSettingsConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new WorkloadSettingsConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwert */ } + return new WorkloadSettingsConfig(); + } +} diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs index 8fc75cf..7955586 100644 --- a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Models; +using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels.Workload; using Xunit; @@ -346,3 +347,102 @@ public sealed class AddTimeEntryDialogViewModelTests Assert.NotEmpty(vm.TimeError); } } + +public sealed class WorkloadEvaluationViewModelTests +{ + private static WorkloadSettingsService BuildSettingsService() + { + var tempPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-workloadsettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new WorkloadSettingsService(tempPath); + } + + [Fact] + public void MonatModus_ZeigtNurEintraegeDesGewaehltenMonats() + { + var entries = new FakeTimeEntries(); + entries.Add(new TimeEntry { Category = "Korrektur", Date = new DateOnly(2026, 3, 10), DurationMinutes = 30 }); + entries.Add(new TimeEntry { Category = "Korrektur", Date = new DateOnly(2026, 4, 1), DurationMinutes = 45 }); + + var vm = new WorkloadEvaluationViewModel(entries, new FakeGroups([]), BuildSettingsService(), new SchoolYearService()) + { + SelectedYear = 2026, + }; + vm.SelectedMonth = vm.MonthOptions[2]; // März + + Assert.Equal("30 min (0.5 h)", vm.TotalMinutesDisplay); + } + + [Fact] + public void CategorySummaries_GruppiertProKategorie() + { + var entries = new FakeTimeEntries(); + entries.Add(new TimeEntry { Category = "Korrektur", Date = new DateOnly(2026, 3, 5), DurationMinutes = 30 }); + entries.Add(new TimeEntry { Category = "Verwaltung", Date = new DateOnly(2026, 3, 6), DurationMinutes = 20 }); + + var vm = new WorkloadEvaluationViewModel(entries, new FakeGroups([]), BuildSettingsService(), new SchoolYearService()) + { + SelectedYear = 2026, + }; + vm.SelectedMonth = vm.MonthOptions[2]; + + Assert.Equal(2, vm.CategorySummaries.Count); + Assert.Contains(vm.CategorySummaries, s => s.Category == "Korrektur" && s.MinutesDisplay == "30 min"); + } + + [Fact] + public void GroupSummaries_ZeigtNurEintraegeMitGruppe() + { + var group = new LearningGroup { Name = "8a" }; + var entries = new FakeTimeEntries(); + entries.Add(new TimeEntry { Category = "X", GroupId = group.Id, Date = new DateOnly(2026, 3, 5), DurationMinutes = 30 }); + entries.Add(new TimeEntry { Category = "X", GroupId = null, Date = new DateOnly(2026, 3, 6), DurationMinutes = 20 }); + + var vm = new WorkloadEvaluationViewModel(entries, new FakeGroups([group]), BuildSettingsService(), new SchoolYearService()) + { + SelectedYear = 2026, + }; + vm.SelectedMonth = vm.MonthOptions[2]; + + var summary = Assert.Single(vm.GroupSummaries); + Assert.Equal("8a", summary.GroupName); + Assert.Equal("30 min", summary.MinutesDisplay); + } + + [Fact] + public void SchuljahrModus_UmfasstAugustBisJuli() + { + var entries = new FakeTimeEntries(); + entries.Add(new TimeEntry { Category = "X", Date = new DateOnly(2025, 9, 1), DurationMinutes = 30 }); // im Schuljahr 2025/26 + entries.Add(new TimeEntry { Category = "X", Date = new DateOnly(2026, 7, 30), DurationMinutes = 15 }); // im Schuljahr 2025/26 + entries.Add(new TimeEntry { Category = "X", Date = new DateOnly(2026, 8, 5), DurationMinutes = 99 }); // schon Schuljahr 2026/27 + + var vm = new WorkloadEvaluationViewModel(entries, new FakeGroups([]), BuildSettingsService(), new SchoolYearService()) + { + SelectedYear = 2025, + PeriodMode = WorkloadEvaluationViewModel.SchoolYearMode, + }; + + Assert.Equal("45 min (0.8 h)", vm.TotalMinutesDisplay); + } + + [Fact] + public void SaveRequiredWeeklyHours_AktualisiertSollIstVergleich() + { + var entries = new FakeTimeEntries(); + entries.Add(new TimeEntry { Category = "X", Date = new DateOnly(2026, 3, 2), DurationMinutes = 60 }); + + var vm = new WorkloadEvaluationViewModel(entries, new FakeGroups([]), BuildSettingsService(), new SchoolYearService()) + { + SelectedYear = 2026, + RequiredWeeklyHoursText = "10", + }; + vm.SelectedMonth = vm.MonthOptions[2]; + + vm.SaveRequiredWeeklyHoursCommand.Execute(null); + + Assert.Contains("Soll:", vm.RequiredVsActualDisplay); + Assert.Contains("Ist: 1 h", vm.RequiredVsActualDisplay); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 9cc20c3..cd26652 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -143,6 +143,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(_ => new SchoolCalendarSettingsService(appData)); services.AddSingleton(_ => new PeriodScheduleService(appData)); + services.AddSingleton(_ => new WorkloadSettingsService(appData)); // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── services.AddSingleton(_ => new EventQueue(queuePath)); @@ -190,6 +191,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Transient: neue Instanz pro Navigation (für Detailseiten) diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 25178e0..03d504e 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -94,6 +94,7 @@ public partial class MainWindowViewModel : ObservableObject var workload = _services.GetRequiredService(); workload.Tasks.Load(); workload.TimeTracking.Load(); + workload.Evaluation.Load(); workload.ActiveTabIndex = 0; return workload; } diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs index 0369226..aaeca16 100644 --- a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Core.Services; using System.Collections.ObjectModel; using System.Globalization; @@ -299,7 +300,7 @@ public partial class TimeTrackingViewModel : ObservableObject : ""; public ObservableCollection WeekEntries { get; } = []; - public ObservableCollection CategorySummaries { get; } = []; + public ObservableCollection CategorySummaries { get; } = []; public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche"; public Func>? OnAddEntry { get; set; } @@ -343,7 +344,7 @@ public partial class TimeTrackingViewModel : ObservableObject foreach (var group in weekEntries.GroupBy(e => e.Category).OrderByDescending(g => g.Sum(e => e.DurationMinutes))) { var minutes = group.Sum(e => e.DurationMinutes); - CategorySummaries.Add(new CategoryWeekSummary( + CategorySummaries.Add(new CategoryTimeSummary( string.IsNullOrWhiteSpace(group.Key) ? "Ohne Kategorie" : group.Key, minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0)); } @@ -413,7 +414,7 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle) public string Description => Model.Description ?? ""; } -public class CategoryWeekSummary(string category, int minutes, double barFraction) +public class CategoryTimeSummary(string category, int minutes, double barFraction) { public string Category { get; } = category; public string MinutesDisplay { get; } = $"{minutes} min"; @@ -489,12 +490,154 @@ public partial class AddTimeEntryDialogViewModel : ObservableObject } } -// ── Container: Arbeitszeit-Seite mit Tabs "Aufgaben"/"Zeiterfassung" ───────── +// ── Auswertung (6.3) ───────────────────────────────────────────────────────── -public partial class WorkloadViewModel(WorkTaskListViewModel tasks, TimeTrackingViewModel timeTracking) : ObservableObject +public partial class WorkloadEvaluationViewModel : ObservableObject +{ + private readonly ITimeEntryRepository _entries; + private readonly IGroupRepository _groups; + private readonly WorkloadSettingsService _workloadSettings; + private readonly SchoolYearService _schoolYear; + + public const string MonthMode = "Monat"; + public const string SchoolYearMode = "Schuljahr"; + + [ObservableProperty] private string _periodMode = MonthMode; + [ObservableProperty] private string _selectedMonth; + [ObservableProperty] private int _selectedYear = DateTime.Today.Year; + [ObservableProperty] private string _requiredWeeklyHoursText = ""; + + public List PeriodModeOptions { get; } = [MonthMode, SchoolYearMode]; + public bool IsMonthMode => PeriodMode == MonthMode; + public List MonthOptions { get; } = Enumerable.Range(1, 12) + .Select(m => CultureInfo.GetCultureInfo("de-DE").DateTimeFormat.GetMonthName(m)).ToList(); + public List YearOptions { get; } = Enumerable.Range(DateTime.Today.Year - 4, 5).Reverse().ToList(); + + public ObservableCollection CategorySummaries { get; } = []; + public ObservableCollection GroupSummaries { get; } = []; + public string TotalMinutesDisplay { get; private set; } = "0 min"; + public string RequiredVsActualDisplay { get; private set; } = ""; + + public WorkloadEvaluationViewModel(ITimeEntryRepository entries, IGroupRepository groups, + WorkloadSettingsService workloadSettings, SchoolYearService schoolYear) + { + _entries = entries; + _groups = groups; + _workloadSettings = workloadSettings; + _schoolYear = schoolYear; + _selectedMonth = MonthOptions[DateTime.Today.Month - 1]; + Load(); + } + + partial void OnPeriodModeChanged(string value) + { + OnPropertyChanged(nameof(IsMonthMode)); + Refresh(); + } + partial void OnSelectedMonthChanged(string value) => Refresh(); + partial void OnSelectedYearChanged(int value) => Refresh(); + + public void Load() + { + RequiredWeeklyHoursText = _workloadSettings.RequiredWeeklyHours > 0 + ? _workloadSettings.RequiredWeeklyHours.ToString(CultureInfo.InvariantCulture) : ""; + Refresh(); + } + + [RelayCommand] + private void SaveRequiredWeeklyHours() + { + if (double.TryParse(RequiredWeeklyHoursText, NumberStyles.Number, CultureInfo.InvariantCulture, out var hours) && hours >= 0) + _workloadSettings.SetRequiredWeeklyHours(hours); + Refresh(); + } + + private (DateOnly From, DateOnly To) CurrentPeriod() + { + if (PeriodMode == SchoolYearMode) + { + // SelectedYear ist hier das Startjahr des Schuljahres (z.B. 2025 -> Schuljahr 2025/26). + var sy = _schoolYear.FormatSchoolYear(SelectedYear); + return (_schoolYear.SchoolYearStart(sy), _schoolYear.SchoolYearEnd(sy)); + } + + var month = MonthOptions.IndexOf(SelectedMonth) + 1; + var from = new DateOnly(SelectedYear, month, 1); + return (from, from.AddMonths(1).AddDays(-1)); + } + + private void Refresh() + { + var (from, to) = CurrentPeriod(); + var periodEntries = _entries.GetByDateRange(from, to); + var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name); + + CategorySummaries.Clear(); + var maxCategoryMinutes = periodEntries.Count > 0 + ? periodEntries.GroupBy(e => e.Category).Max(g => g.Sum(e => e.DurationMinutes)) : 0; + foreach (var group in periodEntries.GroupBy(e => e.Category).OrderByDescending(g => g.Sum(e => e.DurationMinutes))) + { + var minutes = group.Sum(e => e.DurationMinutes); + CategorySummaries.Add(new CategoryTimeSummary( + string.IsNullOrWhiteSpace(group.Key) ? "Ohne Kategorie" : group.Key, + minutes, maxCategoryMinutes > 0 ? minutes / (double)maxCategoryMinutes : 0)); + } + + GroupSummaries.Clear(); + var withGroup = periodEntries.Where(e => e.GroupId.HasValue).ToList(); + var maxGroupMinutes = withGroup.Count > 0 + ? withGroup.GroupBy(e => e.GroupId).Max(g => g.Sum(e => e.DurationMinutes)) : 0; + foreach (var group in withGroup.GroupBy(e => e.GroupId).OrderByDescending(g => g.Sum(e => e.DurationMinutes))) + { + var minutes = group.Sum(e => e.DurationMinutes); + GroupSummaries.Add(new GroupTimeSummary( + groupNames.GetValueOrDefault(group.Key!.Value, "Unbekannte Gruppe"), + minutes, maxGroupMinutes > 0 ? minutes / (double)maxGroupMinutes : 0)); + } + + var totalMinutes = periodEntries.Sum(e => e.DurationMinutes); + TotalMinutesDisplay = $"{totalMinutes} min ({(totalMinutes / 60.0).ToString("0.#", CultureInfo.InvariantCulture)} h)"; + + // Pflichtstunden-Abgleich (6.3.2): auf die Anzahl Wochen im Zeitraum hochgerechnet. + if (_workloadSettings.RequiredWeeklyHours > 0) + { + var weeks = (to.DayNumber - from.DayNumber + 1) / 7.0; + var requiredHours = _workloadSettings.RequiredWeeklyHours * weeks; + var actualHours = totalMinutes / 60.0; + var diff = actualHours - requiredHours; + var diffText = diff >= 0 + ? $"+{diff.ToString("0.#", CultureInfo.InvariantCulture)} h" + : $"{diff.ToString("0.#", CultureInfo.InvariantCulture)} h"; + RequiredVsActualDisplay = + $"Soll: {requiredHours.ToString("0.#", CultureInfo.InvariantCulture)} h · " + + $"Ist: {actualHours.ToString("0.#", CultureInfo.InvariantCulture)} h · {diffText}"; + } + else + { + RequiredVsActualDisplay = ""; + } + + OnPropertyChanged(nameof(TotalMinutesDisplay)); + OnPropertyChanged(nameof(RequiredVsActualDisplay)); + } +} + +public class GroupTimeSummary(string groupName, int minutes, double barFraction) +{ + public string GroupName { get; } = groupName; + public string MinutesDisplay { get; } = $"{minutes} min"; + public double BarFraction { get; } = barFraction; +} + +// ── Container: Arbeitszeit-Seite mit Tabs "Aufgaben"/"Zeiterfassung"/"Auswertung" ─ + +public partial class WorkloadViewModel( + WorkTaskListViewModel tasks, TimeTrackingViewModel timeTracking, WorkloadEvaluationViewModel evaluation) + : ObservableObject { [ObservableProperty] private int _activeTabIndex; public WorkTaskListViewModel Tasks { get; } = tasks; public TimeTrackingViewModel TimeTracking { get; } = timeTracking; + public WorkloadEvaluationViewModel Evaluation { get; } = evaluation; } diff --git a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml index aac8f91..a1e0b99 100644 --- a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml +++ b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml @@ -49,7 +49,7 @@ - + + + + + + + + + + + + + + + + + +