Arbeitszeit: Zeiterfassung (Kapitel 6.2)

Neuer Tab "Zeiterfassung" neben "Aufgaben" (WorkloadViewModel als Tab-Container,
gleiches Muster wie GroupDetailViewModel): Timer mit Zuordnung zu Aufgabe/Kategorie,
manuelle Nacherfassung (Von-Bis oder Dauer), Wochenübersicht nach Kategorie, sowie
"X / Y min erfasst" direkt in der Aufgabenliste als Ist-vs-Soll-Vergleich.
This commit is contained in:
2026-08-15 23:17:44 +02:00
parent 5c0b6854f5
commit d8b05702bc
14 changed files with 707 additions and 38 deletions
@@ -60,6 +60,7 @@ 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";
@@ -81,10 +82,12 @@ public partial class WorkTaskListViewModel : ObservableObject
private Dictionary<Guid, string> _groupNames = [];
public WorkTaskListViewModel(IWorkTaskRepository tasks, IGroupRepository groups)
public WorkTaskListViewModel(IWorkTaskRepository tasks, IGroupRepository groups,
ITimeEntryRepository timeEntries)
{
_tasks = tasks;
_groups = groups;
_timeEntries = timeEntries;
Load();
}
@@ -131,7 +134,11 @@ public partial class WorkTaskListViewModel : ObservableObject
// 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))
Tasks.Add(new WorkTaskListItem(t, _groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)));
{
// 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));
}
@@ -174,7 +181,7 @@ public partial class WorkTaskListViewModel : ObservableObject
}
}
public class WorkTaskListItem(WorkTask model, string? groupName)
public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinutes = 0)
{
public WorkTask Model { get; } = model;
public string Title => Model.Title;
@@ -187,6 +194,12 @@ public class WorkTaskListItem(WorkTask model, string? groupName)
public string StatusLabel => WorkTaskStatusDisplay.Label(Model.Status);
public string StatusColorHex => WorkTaskStatusDisplay.ColorHex(Model.Status);
public string EstimatedMinutesDisplay => Model.EstimatedMinutes is { } m ? $"{m} min" : "";
// 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) ───────────────────────────────
@@ -264,3 +277,224 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
};
}
}
// ── Zeiterfassung (6.2) ──────────────────────────────────────────────────────
public partial class TimeTrackingViewModel : ObservableObject
{
private readonly ITimeEntryRepository _entries;
private readonly IWorkTaskRepository _tasks;
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<CategoryWeekSummary> CategorySummaries { get; } = [];
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks)
{
_entries = entries;
_tasks = tasks;
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 CategoryWeekSummary(
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 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 CategoryWeekSummary(string category, int minutes, double barFraction)
{
public string Category { get; } = category;
public string MinutesDisplay { get; } = $"{minutes} min";
public double BarFraction { get; } = barFraction;
}
// ── Dialog: Zeiteintrag nacherfassen (6.2.2) ─────────────────────────────────
public partial class AddTimeEntryDialogViewModel : ObservableObject
{
[ObservableProperty] private string _dateText = DateTime.Today.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
[ObservableProperty] private string _selectedCategory = TaskCategoryDisplay.Options[0];
[ObservableProperty] private WorkTask? _selectedTask;
[ObservableProperty] private string _startTimeText = "";
[ObservableProperty] private string _endTimeText = "";
[ObservableProperty] private string _durationText = "";
[ObservableProperty] private string _description = "";
[ObservableProperty] private string _dateError = "";
[ObservableProperty] private string _timeError = "";
public List<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(),
};
}
}
// ── Container: Arbeitszeit-Seite mit Tabs "Aufgaben"/"Zeiterfassung" ─────────
public partial class WorkloadViewModel(WorkTaskListViewModel tasks, TimeTrackingViewModel timeTracking) : ObservableObject
{
[ObservableProperty] private int _activeTabIndex;
public WorkTaskListViewModel Tasks { get; } = tasks;
public TimeTrackingViewModel TimeTracking { get; } = timeTracking;
}