Arbeitszeit: Auswertung nach Kategorie/Gruppe + Pflichtstunden-Abgleich (Kapitel 6.3)
Dritter Tab "Auswertung": Zeitraum wahlweise Monat oder Schuljahr, Balken je Kategorie und Gruppe. Neuer WorkloadSettingsService (gleiches JSON-Muster wie PeriodScheduleService) für die Pflichtstundenzahl pro Woche, bewusst direkt im Auswertungs-Tab editierbar statt in den Einstellungen, da das Feld nur dort gebraucht wird. Export (6.3.3) bleibt offen, da er an das noch fehlende Kapitel 11 (Export-Infrastruktur) hängt.
This commit is contained in:
@@ -94,6 +94,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
var workload = _services.GetRequiredService<WorkloadViewModel>();
|
||||
workload.Tasks.Load();
|
||||
workload.TimeTracking.Load();
|
||||
workload.Evaluation.Load();
|
||||
workload.ActiveTabIndex = 0;
|
||||
return workload;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
@@ -299,7 +300,7 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
: "";
|
||||
|
||||
public ObservableCollection<TimeEntryListItem> WeekEntries { get; } = [];
|
||||
public ObservableCollection<CategoryWeekSummary> CategorySummaries { get; } = [];
|
||||
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
|
||||
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
|
||||
|
||||
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
|
||||
@@ -343,7 +344,7 @@ public partial class TimeTrackingViewModel : ObservableObject
|
||||
foreach (var group in weekEntries.GroupBy(e => e.Category).OrderByDescending(g => g.Sum(e => e.DurationMinutes)))
|
||||
{
|
||||
var minutes = group.Sum(e => e.DurationMinutes);
|
||||
CategorySummaries.Add(new CategoryWeekSummary(
|
||||
CategorySummaries.Add(new CategoryTimeSummary(
|
||||
string.IsNullOrWhiteSpace(group.Key) ? "Ohne Kategorie" : group.Key,
|
||||
minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0));
|
||||
}
|
||||
@@ -413,7 +414,7 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle)
|
||||
public string Description => Model.Description ?? "";
|
||||
}
|
||||
|
||||
public class CategoryWeekSummary(string category, int minutes, double barFraction)
|
||||
public class CategoryTimeSummary(string category, int minutes, double barFraction)
|
||||
{
|
||||
public string Category { get; } = category;
|
||||
public string MinutesDisplay { get; } = $"{minutes} min";
|
||||
@@ -489,12 +490,154 @@ public partial class AddTimeEntryDialogViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// ── Container: Arbeitszeit-Seite mit Tabs "Aufgaben"/"Zeiterfassung" ─────────
|
||||
// ── Auswertung (6.3) ─────────────────────────────────────────────────────────
|
||||
|
||||
public partial class WorkloadViewModel(WorkTaskListViewModel tasks, TimeTrackingViewModel timeTracking) : ObservableObject
|
||||
public partial class WorkloadEvaluationViewModel : ObservableObject
|
||||
{
|
||||
private readonly ITimeEntryRepository _entries;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly WorkloadSettingsService _workloadSettings;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
|
||||
public const string MonthMode = "Monat";
|
||||
public const string SchoolYearMode = "Schuljahr";
|
||||
|
||||
[ObservableProperty] private string _periodMode = MonthMode;
|
||||
[ObservableProperty] private string _selectedMonth;
|
||||
[ObservableProperty] private int _selectedYear = DateTime.Today.Year;
|
||||
[ObservableProperty] private string _requiredWeeklyHoursText = "";
|
||||
|
||||
public List<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 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user