Neue Aufgabenart über bestehende Felder (WorkTask.Kind=Reminder + GroupId) statt Vererbung, mit Priorität (TaskPriority) und optionaler Abhak-Liste (ChecklistItems, wahlweise Kurs- Roster oder Freitext). Kurs-Dashboard zeigt jetzt eine "Anstehende Aufgaben"-Karte samt direktem Anlege-Button; derselbe Anlege-Einstieg wurde auch ins Hauptdashboard und (als Schnellüberblick samt fehlender Übersicht/Mitarbeit-Buttons) in die Gruppenliste gezogen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
935 lines
39 KiB
C#
935 lines
39 KiB
C#
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;
|
|
|
|
// 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<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 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 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();
|
|
}
|
|
|
|
/// <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 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;
|
|
}
|