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:
2026-08-15 23:26:06 +02:00
parent d8b05702bc
commit 52bb9c2c2f
10 changed files with 405 additions and 13 deletions
+2
View File
@@ -143,6 +143,7 @@ public static class AppBootstrapper
services.AddSingleton<PublicHolidayService>();
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData));
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
services.AddSingleton(_ => new EventQueue(queuePath));
@@ -190,6 +191,7 @@ public static class AppBootstrapper
services.AddSingleton<TimetableViewModel>();
services.AddSingleton<WorkTaskListViewModel>();
services.AddSingleton<TimeTrackingViewModel>();
services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
@@ -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;
}
@@ -49,7 +49,7 @@
</Grid>
<ItemsControl ItemsSource="{Binding CategorySummaries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CategoryWeekSummary">
<DataTemplate x:DataType="vm:CategoryTimeSummary">
<Grid ColumnDefinitions="120,*,60" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Category}" FontSize="12" VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" Value="{Binding BarFraction}" Maximum="1" Height="8"
@@ -0,0 +1,80 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
x:Class="LehrerApp.Desktop.Views.Workload.WorkloadEvaluationView"
x:DataType="vm:WorkloadEvaluationViewModel">
<ScrollViewer>
<StackPanel Margin="20,16" Spacing="16">
<!-- Zeitraum -->
<Grid ColumnDefinitions="Auto,Auto,Auto" HorizontalAlignment="Left">
<ComboBox Grid.Column="0" ItemsSource="{Binding PeriodModeOptions}"
SelectedItem="{Binding PeriodMode}" Margin="0,0,8,0"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding MonthOptions}" SelectedItem="{Binding SelectedMonth}"
Margin="0,0,8,0" IsVisible="{Binding IsMonthMode}"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding YearOptions}" SelectedItem="{Binding SelectedYear}"/>
</Grid>
<!-- Pflichtstunden (6.3.2) -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
<StackPanel Spacing="10">
<TextBlock Text="Pflichtstunden pro Woche" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="140,Auto">
<TextBox Grid.Column="0" Text="{Binding RequiredWeeklyHoursText}" PlaceholderText="z.B. 25"/>
<Button Grid.Column="1" Content="Speichern" Margin="8,0,0,0"
Command="{Binding SaveRequiredWeeklyHoursCommand}"/>
</Grid>
<TextBlock Text="{Binding RequiredVsActualDisplay}" FontSize="13" FontWeight="SemiBold"
IsVisible="{Binding RequiredVsActualDisplay, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
<!-- Gesamtsumme -->
<TextBlock Text="{Binding TotalMinutesDisplay}" FontSize="18" FontWeight="SemiBold"/>
<!-- Nach Kategorie (6.3.1) -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
<StackPanel Spacing="10">
<TextBlock Text="Nach Kategorie" FontWeight="SemiBold" FontSize="14"/>
<ItemsControl ItemsSource="{Binding CategorySummaries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CategoryTimeSummary">
<Grid ColumnDefinitions="120,*,60" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Category}" FontSize="12" VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" Value="{Binding BarFraction}" Maximum="1" Height="8"
VerticalAlignment="Center" Margin="8,0"/>
<TextBlock Grid.Column="2" Text="{Binding MinutesDisplay}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Zeiten in diesem Zeitraum erfasst." Opacity="0.6" FontSize="12"
IsVisible="{Binding !CategorySummaries.Count}"/>
</StackPanel>
</Border>
<!-- Nach Gruppe (6.3.1) -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
<StackPanel Spacing="10">
<TextBlock Text="Nach Gruppe" FontWeight="SemiBold" FontSize="14"/>
<ItemsControl ItemsSource="{Binding GroupSummaries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupTimeSummary">
<Grid ColumnDefinitions="120,*,60" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding GroupName}" FontSize="12" VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" Value="{Binding BarFraction}" Maximum="1" Height="8"
VerticalAlignment="Center" Margin="8,0"/>
<TextBlock Grid.Column="2" Text="{Binding MinutesDisplay}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine gruppenbezogenen Zeiten in diesem Zeitraum." Opacity="0.6" FontSize="12"
IsVisible="{Binding !GroupSummaries.Count}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Workload;
public partial class WorkloadEvaluationView : UserControl
{
public WorkloadEvaluationView() => InitializeComponent();
}
@@ -19,6 +19,9 @@
<ContentPage Header="Zeiterfassung">
<views:TimeTrackingView DataContext="{Binding TimeTracking}"/>
</ContentPage>
<ContentPage Header="Auswertung">
<views:WorkloadEvaluationView DataContext="{Binding Evaluation}"/>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>