Files
LehrerApp/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs
T
admin 709ea88c2a
CI / build-and-test (push) Canceled after 0s
Arbeitszeitnacherfassung
2026-09-06 13:51:17 +02:00

1015 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.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<TaskCategory>().Select(Label).ToArray();
public static TaskCategory FromLabel(string? label) =>
Enum.GetValues<TaskCategory>().FirstOrDefault(c => Label(c) == label, TaskCategory.Other);
}
public static class TaskPriorityDisplay
{
public static string Label(TaskPriority p) => p switch
{
TaskPriority.Low => "Niedrig",
TaskPriority.Normal => "Normal",
TaskPriority.High => "Hoch",
_ => p.ToString(),
};
public static string ColorHex(TaskPriority p) => p switch
{
TaskPriority.Low => "#9E9E9E",
TaskPriority.Normal => "#1976D2",
TaskPriority.High => "#D32F2F",
_ => "#9E9E9E",
};
public static string[] Options { get; } = Enum.GetValues<TaskPriority>().Select(Label).ToArray();
public static TaskPriority FromLabel(string? label) =>
Enum.GetValues<TaskPriority>().FirstOrDefault(p => Label(p) == label, TaskPriority.Normal);
}
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
{
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<string> StatusFilterOptions { get; } = [ActiveFilter, AllFilter, DoneFilter];
public List<string> CategoryFilterOptions { get; } = [AllFilter, .. TaskCategoryDisplay.Options];
public ObservableCollection<string> GroupFilterOptions { get; } = [AllFilter, NoGroupFilter];
public ObservableCollection<WorkTaskListItem> Tasks { get; } = [];
public string CountSummary => $"{Tasks.Count} Aufgabe(n)";
/// Zweiter Parameter = Dialog startet vorbelegt als Erinnerung (nur bei AddReminder true).
public Func<WorkTask?, bool, Task<WorkTask?>>? OnEditTask { get; set; }
private Dictionary<Guid, string> _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, false);
if (result is null) return;
_tasks.Save(result);
Refresh();
}
/// Eigener Einstieg statt eines Umschalters im selben Dialog-Aufruf wie AddTask (6.1.1
/// Nutzer-Feedback): "Erinnerung" soll sich nicht wie eine Variante der Zeiterfassung anfühlen,
/// sondern wie eine eigene, schnelle Aktion.
[RelayCommand]
private async Task AddReminder()
{
if (OnEditTask is null) return;
var result = await OnEditTask(null, true);
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, false);
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,
Kind = item.Model.Kind,
Priority = item.Model.Priority,
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 bool IsReminder => Model.Kind == TaskKind.Reminder;
public string CategoryLabel => IsReminder ? "Erinnerung" : 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" : "";
// Priorität (Nutzer-Feedback: pädagogische Erinnerungen farblich nach Dringlichkeit absetzen) —
// nur bei High überhaupt anzeigen, Normal/Low sollen nicht zusätzlich "schreien".
public bool ShowPriority => Model.Priority == TaskPriority.High;
public string PriorityLabel => TaskPriorityDisplay.Label(Model.Priority);
public string PriorityColorHex => TaskPriorityDisplay.ColorHex(Model.Priority);
// Abhak-Liste (Nutzer-Feedback: Namensliste/Teil-Punkte optional zuschaltbar).
public bool HasChecklist => Model.ChecklistItems.Count > 0;
public string ChecklistProgressDisplay => Model.ChecklistItems.Count == 0 ? ""
: $"{Model.ChecklistItems.Count(c => c.IsDone)} / {Model.ChecklistItems.Count} erledigt";
// 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;
/// Liefert die aktiven Mitglieder einer Gruppe (StudentId, Anzeigename) für "Aus Kursliste
/// befüllen" — optional/injizierbar statt eines festen Repository-Typs, damit dieses ViewModel
/// (wie bisher) auch ohne DI-Container in Tests per `new` gebaut werden kann.
private readonly Func<Guid, List<(Guid StudentId, string Name)>>? _loadRoster;
[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 _selectedPriority = TaskPriorityDisplay.Label(TaskPriority.Normal);
[ObservableProperty] private string _notes = "";
[ObservableProperty] private bool _isReminder;
[ObservableProperty] private string _newChecklistItemText = "";
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dueDateError = "";
[ObservableProperty] private string _estimatedMinutesError = "";
public string DialogTitle => _source is null
? (IsReminder ? "Erinnerung anlegen" : "Aufgabe anlegen")
: (IsReminder ? "Erinnerung bearbeiten" : "Aufgabe bearbeiten");
public List<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options];
public List<string> PriorityOptions { get; } = [.. TaskPriorityDisplay.Options];
public List<LearningGroup> Groups { get; }
public WorkTask? Result { get; private set; }
// Abhak-Liste (Nutzer-Feedback: wahlweise Namensliste der Kurs-Schüler oder freie Teil-Punkte,
// beides über denselben Zeilentyp).
public ObservableCollection<ChecklistItemRow> ChecklistItems { get; } = [];
public bool CanFillFromRoster => SelectedGroup is not null && _loadRoster is not null;
partial void OnIsReminderChanged(bool value) => OnPropertyChanged(nameof(DialogTitle));
partial void OnSelectedGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(CanFillFromRoster));
public AddEditWorkTaskDialogViewModel(WorkTask? source, List<LearningGroup> groups, bool startAsReminder = false,
Func<Guid, List<(Guid StudentId, string Name)>>? loadRoster = null)
{
_source = source;
Groups = groups;
_loadRoster = loadRoster;
if (source is null) { IsReminder = startAsReminder; 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);
SelectedPriority = TaskPriorityDisplay.Label(source.Priority);
Notes = source.Notes ?? "";
IsReminder = source.Kind == TaskKind.Reminder;
foreach (var item in source.ChecklistItems)
ChecklistItems.Add(new ChecklistItemRow(item));
}
[RelayCommand]
private void AddChecklistItem()
{
if (string.IsNullOrWhiteSpace(NewChecklistItemText)) return;
ChecklistItems.Add(new ChecklistItemRow(NewChecklistItemText.Trim()));
NewChecklistItemText = "";
}
[RelayCommand]
private void RemoveChecklistItem(ChecklistItemRow? row)
{
if (row is not null) ChecklistItems.Remove(row);
}
[RelayCommand]
private void FillFromRoster()
{
if (SelectedGroup is null || _loadRoster is null) return;
var existingStudentIds = ChecklistItems.Where(c => c.StudentId is not null).Select(c => c.StudentId!.Value).ToHashSet();
foreach (var (studentId, name) in _loadRoster(SelectedGroup.Id))
{
if (!existingStudentIds.Contains(studentId))
ChecklistItems.Add(new ChecklistItemRow(name, studentId));
}
}
[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; }
// Erinnerungen haben bewusst keinen Zeitbezug — die Minutenschätzung entfällt.
int? estimatedMinutes = null;
if (!IsReminder && !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,
Kind = IsReminder ? TaskKind.Reminder : TaskKind.WorkItem,
Priority = TaskPriorityDisplay.FromLabel(SelectedPriority),
Status = _source?.Status ?? WorkTaskStatus.Open,
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
ChecklistItems = ChecklistItems.Select(c => new ChecklistItem
{ Id = c.Id, Label = c.Label, IsDone = c.IsDone, StudentId = c.StudentId }).ToList(),
CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow,
};
}
}
public partial class ChecklistItemRow : ObservableObject
{
public Guid Id { get; }
public Guid? StudentId { get; }
[ObservableProperty] private string _label;
[ObservableProperty] private bool _isDone;
public ChecklistItemRow(ChecklistItem source)
{
Id = source.Id;
StudentId = source.StudentId;
_label = source.Label;
_isDone = source.IsDone;
}
public ChecklistItemRow(string label, Guid? studentId = null)
{
Id = Guid.NewGuid();
StudentId = studentId;
_label = label;
}
}
// ── Zeiterfassung (6.2) ──────────────────────────────────────────────────────
public partial class TimeTrackingViewModel : ObservableObject
{
private readonly ITimeEntryRepository _entries;
private readonly IWorkTaskRepository _tasks;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule;
private readonly ISchoolHolidayRepository? _schoolHolidays;
private readonly PublicHolidayService? _publicHolidays;
private readonly SchoolCalendarSettingsService? _calendarSettings;
private readonly ISubstitutionEntryRepository? _substitutions;
// 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;
private const int MissingTeachingTimeLookbackDays = 14;
private const int MissingTeachingTimeTodayDelayMinutes = 30;
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<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<WorkTask> OpenTasks { get; private set; } = [];
public string RunningSinceDisplay => TimerStartedAt is { } started
? $"Läuft seit {started:HH:mm} Uhr"
: "";
public ObservableCollection<TimeEntryListItem> WeekEntries { get; } = [];
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public ObservableCollection<TeachingTimeGapItem> MissingTeachingTimeEntries { 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<Task<TimeEntry?>>? 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<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
public Func<TeachingTimeGapItem, Task<TimeEntry?>>? OnAddMissingTeachingTime { get; set; }
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule,
ISchoolHolidayRepository? schoolHolidays = null, PublicHolidayService? publicHolidays = null,
SchoolCalendarSettingsService? calendarSettings = null,
ISubstitutionEntryRepository? substitutions = null)
{
_entries = entries;
_tasks = tasks;
_timetableSlots = timetableSlots;
_periodSchedule = periodSchedule;
_schoolHolidays = schoolHolidays;
_publicHolidays = publicHolidays;
_calendarSettings = calendarSettings;
_substitutions = substitutions;
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));
}
RefreshMissingTeachingTime(today);
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();
}
[RelayCommand]
private async Task AddMissingTeachingTime(TeachingTimeGapItem? item)
{
if (item is null || OnAddMissingTeachingTime is null) return;
var result = await OnAddMissingTeachingTime(item);
if (result is null) return;
_entries.Save(result);
Refresh();
}
private void RefreshMissingTeachingTime(DateOnly today)
{
MissingTeachingTimeEntries.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var timetableSlots = _timetableSlots.GetAll();
var schoolHolidays = _schoolHolidays?.GetAll() ?? [];
var publicHolidayDates = _publicHolidays is not null && _calendarSettings is not null
? Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet()
: [];
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
var teachingCategory = TaskCategoryDisplay.Label(TaskCategory.Teaching);
for (var date = firstDay; date <= today; date = date.AddDays(1))
{
if (publicHolidayDates.Contains(date)
|| schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate))
continue;
var daySlots = timetableSlots.Where(s => s.Weekday == date.DayOfWeek).ToList();
if (daySlots.Count == 0) continue;
var cancelledPeriods = (_substitutions?.GetByDate(date) ?? [])
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
.Where(t => t is not null).Select(t => t!.Value).ToList();
if (periodTimes.Count == 0) continue;
var lastPeriodEnd = periodTimes.Max(t => t.End);
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
continue;
if (_entries.GetByDate(date).Any(e => e.Category == teachingCategory)) continue;
MissingTeachingTimeEntries.Add(new TeachingTimeGapItem(
date,
periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes),
lastPeriodEnd.AddMinutes(BufferAfterLastPeriodMinutes)));
}
}
/// <summary>
/// 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.
/// </summary>
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 sealed class TeachingTimeGapItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
{
private static readonly CultureInfo De = new("de-DE");
public DateOnly Date { get; } = date;
public TimeOnly WindowStart { get; } = windowStart;
public TimeOnly WindowEnd { get; } = windowEnd;
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
public string TimeDisplay { get; } = $"{windowStart:HH:mm}{windowEnd:HH:mm} Uhr";
}
public class CategoryTimeSummary(string category, int minutes, double barFraction)
{
public string Category { get; } = category;
public int Minutes { get; } = minutes;
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<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<WorkTask> Tasks { get; }
public TimeEntry? Result { get; private set; }
public AddTimeEntryDialogViewModel(List<WorkTask> 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<string> PeriodModeOptions { get; } = [MonthMode, SchoolYearMode];
public bool IsMonthMode => PeriodMode == MonthMode;
public List<string> MonthOptions { get; } = Enumerable.Range(1, 12)
.Select(m => CultureInfo.GetCultureInfo("de-DE").DateTimeFormat.GetMonthName(m)).ToList();
public List<int> YearOptions { get; } = Enumerable.Range(DateTime.Today.Year - 4, 5).Reverse().ToList();
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public ObservableCollection<GroupTimeSummary> GroupSummaries { get; } = [];
public string TotalMinutesDisplay { get; private set; } = "0 min";
public string RequiredVsActualDisplay { get; private set; } = "";
public string ExportSuggestedFileName
{
get
{
if (PeriodMode == SchoolYearMode)
return $"Arbeitszeitauswertung_Schuljahr_{SelectedYear}-{SelectedYear + 1}.csv";
var month = MonthOptions.IndexOf(SelectedMonth) + 1;
return $"Arbeitszeitauswertung_{SelectedYear}-{month:00}.csv";
}
}
private DateOnly _periodFrom;
private DateOnly _periodTo;
private int _totalMinutes;
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();
_periodFrom = from;
_periodTo = to;
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);
_totalMinutes = totalMinutes;
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));
OnPropertyChanged(nameof(ExportSuggestedFileName));
}
public string ExportCsv()
{
var germanCulture = CultureInfo.GetCultureInfo("de-DE");
var csv = new CsvBuilder()
.AddRow("Arbeitszeitauswertung")
.AddRow("Zeitraum", _periodFrom.ToString("dd.MM.yyyy", germanCulture),
_periodTo.ToString("dd.MM.yyyy", germanCulture))
.AddRow("Gesamtzeit (Minuten)", _totalMinutes)
.AddRow("Gesamtzeit (Stunden)", (_totalMinutes / 60.0).ToString("0.##", germanCulture));
if (_workloadSettings.RequiredWeeklyHours > 0)
{
var weeks = (_periodTo.DayNumber - _periodFrom.DayNumber + 1) / 7.0;
var requiredHours = _workloadSettings.RequiredWeeklyHours * weeks;
var actualHours = _totalMinutes / 60.0;
csv.AddRow("Pflichtstunden pro Woche",
_workloadSettings.RequiredWeeklyHours.ToString("0.##", germanCulture))
.AddRow("Sollzeit (Stunden)", requiredHours.ToString("0.##", germanCulture))
.AddRow("Abweichung (Stunden)", (actualHours - requiredHours).ToString("0.##", germanCulture));
}
csv.AddBlankRow()
.AddRow("Nach Kategorie")
.AddRow("Kategorie", "Minuten", "Stunden");
foreach (var summary in CategorySummaries)
csv.AddRow(summary.Category, summary.Minutes,
(summary.Minutes / 60.0).ToString("0.##", germanCulture));
csv.AddBlankRow()
.AddRow("Nach Gruppe")
.AddRow("Gruppe", "Minuten", "Stunden");
foreach (var summary in GroupSummaries)
csv.AddRow(summary.GroupName, summary.Minutes,
(summary.Minutes / 60.0).ToString("0.##", germanCulture));
return csv.ToString();
}
}
public class GroupTimeSummary(string groupName, int minutes, double barFraction)
{
public string GroupName { get; } = groupName;
public int Minutes { get; } = minutes;
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;
}