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; 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", TaskCategory.Teaching => "Unterricht", _ => 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); } 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 { private readonly IWorkTaskRepository _tasks; private readonly IGroupRepository _groups; private readonly ITimeEntryRepository _timeEntries; 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, ITimeEntryRepository timeEntries) { _tasks = tasks; _groups = groups; _timeEntries = timeEntries; 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)) { // Ist-Zeit (6.2.4): Summe aller mit der Aufgabe verknüpften Zeiteinträge. var actualMinutes = _timeEntries.GetByTask(t.Id).Sum(e => e.DurationMinutes); Tasks.Add(new WorkTaskListItem(t, _groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty), actualMinutes)); } 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; 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(); } [RelayCommand] private void DeleteTask(WorkTaskListItem? item) { if (item is null) return; _tasks.Delete(item.Model.Id); Refresh(); } } public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinutes = 0) { 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" : ""; // 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 ? $"{actualMinutes} / {estimate} min erfasst" : $"{actualMinutes} min erfasst"; } // ── 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 _selectedRecurrence = TaskRecurrenceDisplay.Options[0]; [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 RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.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) ?? ""; SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence); 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; } 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)) { 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, Recurrence = recurrence, Status = _source?.Status ?? WorkTaskStatus.Open, Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(), CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow, }; } } // ── Zeiterfassung (6.2) ────────────────────────────────────────────────────── public partial class TimeTrackingViewModel : ObservableObject { private readonly ITimeEntryRepository _entries; private readonly IWorkTaskRepository _tasks; private readonly ITimetableSlotRepository _timetableSlots; private readonly PeriodScheduleService _periodSchedule; // Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch // nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible // Puffer für den Unterrichtszeit-Vorschlag. Der Vorschlag füllt nur den Nacherfassen-Dialog // vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime). private const int BufferBeforeFirstPeriodMinutes = 15; private const int BufferAfterLastPeriodMinutes = 10; public const string NoTaskOption = "Keine Aufgabe"; [ObservableProperty] private bool _isTimerRunning; [ObservableProperty] private DateTime? _timerStartedAt; [ObservableProperty] private WorkTask? _selectedTimerTask; [ObservableProperty] private string _selectedTimerCategory = TaskCategoryDisplay.Options[0]; public List CategoryOptions { get; } = [.. TaskCategoryDisplay.Options]; public List OpenTasks { get; private set; } = []; public string RunningSinceDisplay => TimerStartedAt is { } started ? $"Läuft seit {started:HH:mm} Uhr" : ""; public ObservableCollection WeekEntries { get; } = []; public ObservableCollection CategorySummaries { get; } = []; public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche"; /// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der /// "Unterrichtszeit übernehmen"-Button überhaupt sichtbar ist. public bool HasTeachingTimeSuggestionToday => ComputeTodaysTeachingWindow() is not null; public Func>? OnAddEntry { get; set; } /// Wie OnAddEntry, aber öffnet den Dialog mit vorbefüllter Kategorie "Unterricht" und den /// laut Stundenplan/Stundenraster vorgeschlagenen Beginn-/Ende-Zeiten - eine echte Bestätigung /// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer- /// Konstanten oben). public Func>? OnSuggestTeachingTime { get; set; } public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks, ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule) { _entries = entries; _tasks = tasks; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; Load(); } partial void OnSelectedTimerTaskChanged(WorkTask? value) { if (value is not null) SelectedTimerCategory = TaskCategoryDisplay.Label(value.Category); } public void Load() { OpenTasks = _tasks.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList(); OnPropertyChanged(nameof(OpenTasks)); Refresh(); } private void Refresh() { var today = DateOnly.FromDateTime(DateTime.Today); var monday = today.AddDays(-((int)today.DayOfWeek + 6) % 7); var sunday = monday.AddDays(6); var weekEntries = _entries.GetByDateRange(monday, sunday); var taskTitles = _tasks.GetAll().ToDictionary(t => t.Id, t => t.Title); WeekEntries.Clear(); foreach (var e in weekEntries.OrderByDescending(e => e.Date).ThenByDescending(e => e.StartTime)) WeekEntries.Add(new TimeEntryListItem(e, e.TaskId is { } id ? taskTitles.GetValueOrDefault(id) : null)); CategorySummaries.Clear(); var maxMinutes = weekEntries.Count > 0 ? weekEntries.GroupBy(e => e.Category).Max(g => g.Sum(e => e.DurationMinutes)) : 0; 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 CategoryTimeSummary( string.IsNullOrWhiteSpace(group.Key) ? "Ohne Kategorie" : group.Key, minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0)); } OnPropertyChanged(nameof(TotalWeekMinutesDisplay)); } [RelayCommand] private void StartTimer() { IsTimerRunning = true; TimerStartedAt = DateTime.Now; OnPropertyChanged(nameof(RunningSinceDisplay)); } [RelayCommand] private void StopTimer() { if (TimerStartedAt is not { } startedAt) return; var endedAt = DateTime.Now; var minutes = Math.Max(1, (int)Math.Round((endedAt - startedAt).TotalMinutes)); _entries.Save(new TimeEntry { TaskId = SelectedTimerTask?.Id, Category = SelectedTimerCategory, GroupId = SelectedTimerTask?.GroupId, Date = DateOnly.FromDateTime(startedAt), StartTime = TimeOnly.FromDateTime(startedAt), EndTime = TimeOnly.FromDateTime(endedAt), DurationMinutes = minutes, }); IsTimerRunning = false; TimerStartedAt = null; OnPropertyChanged(nameof(RunningSinceDisplay)); Refresh(); } [RelayCommand] private async Task AddEntry() { if (OnAddEntry is null) return; var result = await OnAddEntry(); if (result is null) return; _entries.Save(result); Refresh(); } [RelayCommand] private async Task SuggestTeachingTime() { if (OnSuggestTeachingTime is null || ComputeTodaysTeachingWindow() is not var (start, end)) return; var result = await OnSuggestTeachingTime(start, end); if (result is null) return; _entries.Save(result); Refresh(); } /// /// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht /// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten /// Puffer erweitert. Ohne Stundenplan-Eintrag heute oder ohne im Stundenraster hinterlegte /// Zeiten gibt es keinen Vorschlag (null) statt einer erfundenen Zeit. /// private (TimeOnly Start, TimeOnly End)? ComputeTodaysTeachingWindow() { var today = DateOnly.FromDateTime(DateTime.Today).DayOfWeek; var periodTimes = _timetableSlots.GetAll() .Where(s => s.Weekday == today) .Select(s => _periodSchedule.GetTimes(s.PeriodNumber)) .Where(t => t is not null) .Select(t => t!.Value) .ToList(); if (periodTimes.Count == 0) return null; var start = periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes); var end = periodTimes.Max(t => t.End).AddMinutes(BufferAfterLastPeriodMinutes); return (start, end); } [RelayCommand] private void DeleteEntry(TimeEntryListItem? item) { if (item is null) return; _entries.Delete(item.Model.Id); Refresh(); } } public class TimeEntryListItem(TimeEntry model, string? taskTitle) { public TimeEntry Model { get; } = model; public string DateDisplay => Model.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); public string Category => string.IsNullOrWhiteSpace(Model.Category) ? "Ohne Kategorie" : Model.Category; public string TaskTitle => taskTitle ?? ""; public string CategoryAndTaskDisplay => string.IsNullOrEmpty(TaskTitle) ? Category : $"{Category} · {TaskTitle}"; public string DurationDisplay => $"{Model.DurationMinutes} min"; public string Description => Model.Description ?? ""; } public class CategoryTimeSummary(string category, int minutes, double barFraction) { public string Category { get; } = category; public string MinutesDisplay { get; } = $"{minutes} min"; public double BarFraction { get; } = barFraction; } // ── Dialog: Zeiteintrag nacherfassen (6.2.2) ───────────────────────────────── public partial class AddTimeEntryDialogViewModel : ObservableObject { [ObservableProperty] private string _dateText = DateTime.Today.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); [ObservableProperty] private string _selectedCategory = TaskCategoryDisplay.Options[0]; [ObservableProperty] private WorkTask? _selectedTask; [ObservableProperty] private string _startTimeText = ""; [ObservableProperty] private string _endTimeText = ""; [ObservableProperty] private string _durationText = ""; [ObservableProperty] private string _description = ""; [ObservableProperty] private string _dateError = ""; [ObservableProperty] private string _timeError = ""; public List CategoryOptions { get; } = [.. TaskCategoryDisplay.Options]; public List Tasks { get; } public TimeEntry? Result { get; private set; } public AddTimeEntryDialogViewModel(List tasks) => Tasks = tasks; partial void OnSelectedTaskChanged(WorkTask? value) { if (value is not null) SelectedCategory = TaskCategoryDisplay.Label(value.Category); } [RelayCommand] private void Save() { DateError = ""; TimeError = ""; var valid = true; if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date)) { DateError = "Format TT.MM.JJJJ."; valid = false; } TimeOnly? start = null, end = null; int? duration = null; if (!string.IsNullOrWhiteSpace(DurationText)) { if (!int.TryParse(DurationText, out var m) || m <= 0) { TimeError = "Dauer: ganze Zahl > 0 erwartet."; valid = false; } else duration = m; } else if (!string.IsNullOrWhiteSpace(StartTimeText) || !string.IsNullOrWhiteSpace(EndTimeText)) { if (!TimeOnly.TryParseExact(StartTimeText, "HH:mm", null, DateTimeStyles.None, out var s) || !TimeOnly.TryParseExact(EndTimeText, "HH:mm", null, DateTimeStyles.None, out var e) || e <= s) { TimeError = "Format HH:mm, Ende muss nach Beginn liegen."; valid = false; } else { start = s; end = e; duration = (int)(e - s).TotalMinutes; } } else { TimeError = "Dauer oder Von/Bis erforderlich."; valid = false; } if (!valid) return; Result = new TimeEntry { TaskId = SelectedTask?.Id, Category = SelectedCategory, GroupId = SelectedTask?.GroupId, Date = date, StartTime = start, EndTime = end, DurationMinutes = duration!.Value, Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(), }; } } // ── Auswertung (6.3) ───────────────────────────────────────────────────────── 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; }