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:
@@ -280,6 +280,18 @@ public class FakeWorkTasks : IWorkTaskRepository
|
|||||||
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
|
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class FakeTimeEntries : ITimeEntryRepository
|
||||||
|
{
|
||||||
|
private readonly List<TimeEntry> _all = [];
|
||||||
|
public void Add(TimeEntry e) => _all.Add(e);
|
||||||
|
public List<TimeEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
|
||||||
|
public List<TimeEntry> GetByDateRange(DateOnly from, DateOnly to) =>
|
||||||
|
_all.Where(e => e.Date >= from && e.Date <= to).ToList();
|
||||||
|
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList();
|
||||||
|
public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
|
||||||
|
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
public class FakeReportGrades : IReportGradeRepository
|
public class FakeReportGrades : IReportGradeRepository
|
||||||
{
|
{
|
||||||
private readonly List<ReportGrade> _all = [];
|
private readonly List<ReportGrade> _all = [];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(new WorkTask { Title = "Offen", Status = WorkTaskStatus.Open });
|
tasks.Add(new WorkTask { Title = "Offen", Status = WorkTaskStatus.Open });
|
||||||
tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done });
|
tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
||||||
|
|
||||||
Assert.Single(vm.Tasks);
|
Assert.Single(vm.Tasks);
|
||||||
Assert.Equal("Offen", vm.Tasks[0].Title);
|
Assert.Equal("Offen", vm.Tasks[0].Title);
|
||||||
@@ -23,7 +23,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
{
|
{
|
||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done });
|
tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
|
||||||
{
|
{
|
||||||
StatusFilter = WorkTaskListViewModel.AllFilter,
|
StatusFilter = WorkTaskListViewModel.AllFilter,
|
||||||
};
|
};
|
||||||
@@ -37,7 +37,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(new WorkTask { Title = "Korrektur", Category = TaskCategory.Correction });
|
tasks.Add(new WorkTask { Title = "Korrektur", Category = TaskCategory.Correction });
|
||||||
tasks.Add(new WorkTask { Title = "Vorbereitung", Category = TaskCategory.Preparation });
|
tasks.Add(new WorkTask { Title = "Vorbereitung", Category = TaskCategory.Preparation });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
|
||||||
{
|
{
|
||||||
CategoryFilter = TaskCategoryDisplay.Label(TaskCategory.Correction),
|
CategoryFilter = TaskCategoryDisplay.Label(TaskCategory.Correction),
|
||||||
};
|
};
|
||||||
@@ -53,7 +53,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(new WorkTask { Title = "Mit Gruppe", GroupId = group.Id });
|
tasks.Add(new WorkTask { Title = "Mit Gruppe", GroupId = group.Id });
|
||||||
tasks.Add(new WorkTask { Title = "Ohne Gruppe", GroupId = null });
|
tasks.Add(new WorkTask { Title = "Ohne Gruppe", GroupId = null });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([group]))
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([group]), new FakeTimeEntries())
|
||||||
{
|
{
|
||||||
GroupFilter = WorkTaskListViewModel.NoGroupFilter,
|
GroupFilter = WorkTaskListViewModel.NoGroupFilter,
|
||||||
};
|
};
|
||||||
@@ -67,7 +67,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
{
|
{
|
||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Open });
|
tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Open });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
|
||||||
{
|
{
|
||||||
StatusFilter = WorkTaskListViewModel.AllFilter,
|
StatusFilter = WorkTaskListViewModel.AllFilter,
|
||||||
};
|
};
|
||||||
@@ -82,7 +82,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
{
|
{
|
||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Done });
|
tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Done });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
|
||||||
{
|
{
|
||||||
StatusFilter = WorkTaskListViewModel.AllFilter,
|
StatusFilter = WorkTaskListViewModel.AllFilter,
|
||||||
};
|
};
|
||||||
@@ -97,7 +97,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
{
|
{
|
||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(new WorkTask { Title = "T" });
|
tasks.Add(new WorkTask { Title = "T" });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
||||||
|
|
||||||
vm.DeleteTaskCommand.Execute(vm.Tasks[0]);
|
vm.DeleteTaskCommand.Execute(vm.Tasks[0]);
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
public async Task AddTask_RuftOnEditTaskAufUndSpeichertErgebnis()
|
public async Task AddTask_RuftOnEditTaskAufUndSpeichertErgebnis()
|
||||||
{
|
{
|
||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
||||||
vm.OnEditTask = _ => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
|
vm.OnEditTask = _ => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
|
||||||
|
|
||||||
await vm.AddTaskCommand.ExecuteAsync(null);
|
await vm.AddTaskCommand.ExecuteAsync(null);
|
||||||
@@ -124,7 +124,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
tasks.Add(new WorkTask { Title = "Ohne Frist", DueDate = null });
|
tasks.Add(new WorkTask { Title = "Ohne Frist", DueDate = null });
|
||||||
tasks.Add(new WorkTask { Title = "Spät fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(10)) });
|
tasks.Add(new WorkTask { Title = "Spät fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(10)) });
|
||||||
tasks.Add(new WorkTask { Title = "Bald fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(1)) });
|
tasks.Add(new WorkTask { Title = "Bald fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(1)) });
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
||||||
|
|
||||||
Assert.Equal(["Bald fällig", "Spät fällig", "Ohne Frist"], vm.Tasks.Select(t => t.Title));
|
Assert.Equal(["Bald fällig", "Spät fällig", "Ohne Frist"], vm.Tasks.Select(t => t.Title));
|
||||||
}
|
}
|
||||||
@@ -193,3 +193,156 @@ public sealed class AddEditWorkTaskDialogViewModelTests
|
|||||||
Assert.Equal(WorkTaskStatus.InProgress, vm.Result.Status);
|
Assert.Equal(WorkTaskStatus.InProgress, vm.Result.Status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class TimeTrackingViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
|
||||||
|
{
|
||||||
|
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.StartTimerCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsTimerRunning);
|
||||||
|
|
||||||
|
vm.StopTimerCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.False(vm.IsTimerRunning);
|
||||||
|
var saved = Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
|
||||||
|
Assert.True(saved.DurationMinutes >= 1);
|
||||||
|
Assert.Equal(TaskCategoryDisplay.Label(TaskCategory.Correction), saved.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedTimerTask_UebernimmtDessenKategorie()
|
||||||
|
{
|
||||||
|
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
|
||||||
|
var tasks = new FakeWorkTasks();
|
||||||
|
tasks.Add(task);
|
||||||
|
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
|
||||||
|
|
||||||
|
vm.SelectedTimerTask = task;
|
||||||
|
|
||||||
|
Assert.Equal(TaskCategoryDisplay.Label(TaskCategory.Meeting), vm.SelectedTimerCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ZeigtNurEintraegeDerAktuellenWoche()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||||
|
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
|
||||||
|
|
||||||
|
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||||
|
|
||||||
|
Assert.Single(vm.WeekEntries);
|
||||||
|
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CategorySummaries_GruppiertUndSummiertProKategorie()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 30 });
|
||||||
|
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
|
||||||
|
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
|
||||||
|
|
||||||
|
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||||
|
|
||||||
|
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
|
||||||
|
Assert.Equal("50 min", korrektur.MinutesDisplay);
|
||||||
|
Assert.Equal(1.0, korrektur.BarFraction);
|
||||||
|
Assert.Equal("60 min diese Woche", vm.TotalWeekMinutesDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteEntry_EntferntEintragAusDerWoche()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||||
|
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||||
|
|
||||||
|
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
|
||||||
|
|
||||||
|
Assert.Empty(vm.WeekEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||||
|
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
|
||||||
|
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
|
||||||
|
|
||||||
|
await vm.AddEntryCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Single(vm.WeekEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AddTimeEntryDialogViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Save_MitDauer_ErzeugtEintrag()
|
||||||
|
{
|
||||||
|
var vm = new AddTimeEntryDialogViewModel([])
|
||||||
|
{
|
||||||
|
DateText = "10.01.2026",
|
||||||
|
DurationText = "45",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(45, vm.Result!.DurationMinutes);
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 10), vm.Result.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_MitVonBis_BerechnetDauer()
|
||||||
|
{
|
||||||
|
var vm = new AddTimeEntryDialogViewModel([])
|
||||||
|
{
|
||||||
|
DateText = "10.01.2026",
|
||||||
|
StartTimeText = "14:00",
|
||||||
|
EndTimeText = "14:30",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(30, vm.Result!.DurationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_OhneDauerUndOhneZeiten_SetztFehler()
|
||||||
|
{
|
||||||
|
var vm = new AddTimeEntryDialogViewModel([]) { DateText = "10.01.2026" };
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Null(vm.Result);
|
||||||
|
Assert.NotEmpty(vm.TimeError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_EndeVorBeginn_SetztFehler()
|
||||||
|
{
|
||||||
|
var vm = new AddTimeEntryDialogViewModel([])
|
||||||
|
{
|
||||||
|
DateText = "10.01.2026",
|
||||||
|
StartTimeText = "14:30",
|
||||||
|
EndTimeText = "14:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Null(vm.Result);
|
||||||
|
Assert.NotEmpty(vm.TimeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<StudentListViewModel>();
|
services.AddSingleton<StudentListViewModel>();
|
||||||
services.AddSingleton<TimetableViewModel>();
|
services.AddSingleton<TimetableViewModel>();
|
||||||
services.AddSingleton<WorkTaskListViewModel>();
|
services.AddSingleton<WorkTaskListViewModel>();
|
||||||
|
services.AddSingleton<TimeTrackingViewModel>();
|
||||||
|
services.AddSingleton<WorkloadViewModel>();
|
||||||
|
|
||||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||||
services.AddTransient<GroupDetailViewModel>();
|
services.AddTransient<GroupDetailViewModel>();
|
||||||
|
|||||||
@@ -89,10 +89,12 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
return timetable;
|
return timetable;
|
||||||
}
|
}
|
||||||
|
|
||||||
private WorkTaskListViewModel GetWorkload()
|
private WorkloadViewModel GetWorkload()
|
||||||
{
|
{
|
||||||
var workload = _services.GetRequiredService<WorkTaskListViewModel>();
|
var workload = _services.GetRequiredService<WorkloadViewModel>();
|
||||||
workload.Load();
|
workload.Tasks.Load();
|
||||||
|
workload.TimeTracking.Load();
|
||||||
|
workload.ActiveTabIndex = 0;
|
||||||
return workload;
|
return workload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public partial class WorkTaskListViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly IWorkTaskRepository _tasks;
|
private readonly IWorkTaskRepository _tasks;
|
||||||
private readonly IGroupRepository _groups;
|
private readonly IGroupRepository _groups;
|
||||||
|
private readonly ITimeEntryRepository _timeEntries;
|
||||||
|
|
||||||
public const string AllFilter = "Alle";
|
public const string AllFilter = "Alle";
|
||||||
public const string ActiveFilter = "Offene Aufgaben";
|
public const string ActiveFilter = "Offene Aufgaben";
|
||||||
@@ -81,10 +82,12 @@ public partial class WorkTaskListViewModel : ObservableObject
|
|||||||
|
|
||||||
private Dictionary<Guid, string> _groupNames = [];
|
private Dictionary<Guid, string> _groupNames = [];
|
||||||
|
|
||||||
public WorkTaskListViewModel(IWorkTaskRepository tasks, IGroupRepository groups)
|
public WorkTaskListViewModel(IWorkTaskRepository tasks, IGroupRepository groups,
|
||||||
|
ITimeEntryRepository timeEntries)
|
||||||
{
|
{
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
_groups = groups;
|
_groups = groups;
|
||||||
|
_timeEntries = timeEntries;
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +134,11 @@ public partial class WorkTaskListViewModel : ObservableObject
|
|||||||
|
|
||||||
// Fälligkeit: fällige zuerst, unbefristete ans Ende (6.1.1).
|
// 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))
|
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));
|
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 WorkTask Model { get; } = model;
|
||||||
public string Title => Model.Title;
|
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 StatusLabel => WorkTaskStatusDisplay.Label(Model.Status);
|
||||||
public string StatusColorHex => WorkTaskStatusDisplay.ColorHex(Model.Status);
|
public string StatusColorHex => WorkTaskStatusDisplay.ColorHex(Model.Status);
|
||||||
public string EstimatedMinutesDisplay => Model.EstimatedMinutes is { } m ? $"{m} min" : "";
|
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) ───────────────────────────────
|
// ── 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@
|
|||||||
<DataTemplate DataType="vmp:TimetableViewModel">
|
<DataTemplate DataType="vmp:TimetableViewModel">
|
||||||
<vp:TimetableView/>
|
<vp:TimetableView/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
<DataTemplate DataType="vmw:WorkTaskListViewModel">
|
<DataTemplate DataType="vmw:WorkloadViewModel">
|
||||||
<vw:WorkTaskListView/>
|
<vw:WorkloadView/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
<DataTemplate DataType="vm:PlaceholderViewModel">
|
<DataTemplate DataType="vm:PlaceholderViewModel">
|
||||||
<views:PlaceholderView/>
|
<views:PlaceholderView/>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
||||||
|
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Workload.AddTimeEntryDialog"
|
||||||
|
x:DataType="vm:AddTimeEntryDialogViewModel"
|
||||||
|
Title="Zeit nacherfassen"
|
||||||
|
Width="420" Height="440" MinWidth="380" MinHeight="400"
|
||||||
|
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
|
<ScrollViewer Grid.Row="0">
|
||||||
|
<StackPanel Spacing="14" Margin="0,0,12,0">
|
||||||
|
<TextBlock Text="Zeit nacherfassen" Classes="dialogtitle"/>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Kategorie" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ComboBox ItemsSource="{Binding CategoryOptions}" SelectedItem="{Binding SelectedCategory}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Aufgabe (optional)" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Tasks}" SelectedItem="{Binding SelectedTask}"
|
||||||
|
HorizontalAlignment="Stretch" PlaceholderText="Keine Aufgabe">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:WorkTask">
|
||||||
|
<TextBlock Text="{Binding Title}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Von" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding StartTimeText}" PlaceholderText="HH:mm"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Bis" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding EndTimeText}" PlaceholderText="HH:mm"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Oder Dauer in min" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding DurationText}" PlaceholderText="z.B. 30"/>
|
||||||
|
<TextBlock Text="{Binding TimeError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding TimeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Notiz (optional)" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding Description}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Workload;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Workload;
|
||||||
|
|
||||||
|
public partial class AddTimeEntryDialog : Window
|
||||||
|
{
|
||||||
|
public AddTimeEntryDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnSave(object? s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is AddTimeEntryDialogViewModel vm && vm.SaveCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
if (vm.Result is not null) Close(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
||||||
|
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Workload.TimeTrackingView"
|
||||||
|
x:DataType="vm:TimeTrackingViewModel">
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Margin="20,16" Spacing="16">
|
||||||
|
|
||||||
|
<!-- Timer (6.2.1) -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="Timer" FontWeight="SemiBold" FontSize="14"/>
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsEnabled="{Binding !IsTimerRunning}">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Aufgabe (optional)" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ComboBox ItemsSource="{Binding OpenTasks}" SelectedItem="{Binding SelectedTimerTask}"
|
||||||
|
HorizontalAlignment="Stretch" PlaceholderText="Keine Aufgabe">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:WorkTask">
|
||||||
|
<TextBlock Text="{Binding Title}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Kategorie" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ComboBox ItemsSource="{Binding CategoryOptions}" SelectedItem="{Binding SelectedTimerCategory}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Button Grid.Column="0" Content="▶ Start" Command="{Binding StartTimerCommand}"
|
||||||
|
IsVisible="{Binding !IsTimerRunning}"/>
|
||||||
|
<Button Grid.Column="0" Content="■ Stop" Command="{Binding StopTimerCommand}"
|
||||||
|
IsVisible="{Binding IsTimerRunning}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding RunningSinceDisplay}" VerticalAlignment="Center"
|
||||||
|
Margin="12,0,0,0" FontWeight="SemiBold"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Wochenübersicht (6.2.3) -->
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8" Padding="16">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="Diese Woche" FontWeight="SemiBold" FontSize="14"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding TotalWeekMinutesDisplay}" Opacity="0.7" FontSize="12"/>
|
||||||
|
</Grid>
|
||||||
|
<ItemsControl ItemsSource="{Binding CategorySummaries}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:CategoryWeekSummary">
|
||||||
|
<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="Noch keine Zeiten diese Woche erfasst." Opacity="0.6" FontSize="12"
|
||||||
|
IsVisible="{Binding !CategorySummaries.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Einträge / Nacherfassung (6.2.2) -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Content="+ Nacherfassen" Command="{Binding AddEntryCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding WeekEntries}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TimeEntryListItem">
|
||||||
|
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
CornerRadius="6" Padding="12,8" Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="80,*,Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="12" Opacity="0.6"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="1" Margin="8,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding CategoryAndTaskDisplay}" FontSize="13"/>
|
||||||
|
<TextBlock Text="{Binding Description}" FontSize="11" Opacity="0.6"
|
||||||
|
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding DurationDisplay}" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,10,0" FontSize="12"/>
|
||||||
|
<Button Grid.Column="3" Content="Löschen" FontSize="11" Padding="8,3"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:TimeTrackingViewModel)DataContext).DeleteEntryCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Noch keine Einträge diese Woche." Opacity="0.6" Margin="4,4"
|
||||||
|
IsVisible="{Binding !WeekEntries.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Workload;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Workload;
|
||||||
|
|
||||||
|
public partial class TimeTrackingView : UserControl
|
||||||
|
{
|
||||||
|
public TimeTrackingView() => InitializeComponent();
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is TimeTrackingViewModel vm)
|
||||||
|
vm.OnAddEntry = ShowAddEntryDialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TimeEntry?> ShowAddEntryDialog()
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null) return null;
|
||||||
|
|
||||||
|
var tasks = App.Services.GetRequiredService<IWorkTaskRepository>()
|
||||||
|
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||||
|
var vm = new AddTimeEntryDialogViewModel(tasks);
|
||||||
|
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||||
|
await dialog.ShowDialog<bool>(owner);
|
||||||
|
return vm.Result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,20 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
||||||
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
|
||||||
x:Class="LehrerApp.Desktop.Views.Workload.WorkTaskListView"
|
x:Class="LehrerApp.Desktop.Views.Workload.WorkTaskListView"
|
||||||
x:DataType="vm:WorkTaskListViewModel">
|
x:DataType="vm:WorkTaskListViewModel">
|
||||||
<Grid RowDefinitions="Auto,Auto,*">
|
<Grid RowDefinitions="Auto,*">
|
||||||
<Border Grid.Row="0" Padding="20,16"
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,*,Auto" Margin="16,14,16,4" VerticalAlignment="Center">
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
|
||||||
BorderThickness="0,0,0,1">
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
|
||||||
<shared:PageHeader Grid.Column="0" Title="Arbeitszeit" Subtitle="{Binding CountSummary}"/>
|
|
||||||
<Button Grid.Column="1" Content="+ Neue Aufgabe"
|
|
||||||
Command="{Binding AddTaskCommand}" VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,Auto" Margin="16,10" HorizontalAlignment="Left">
|
|
||||||
<ComboBox Grid.Column="0" ItemsSource="{Binding StatusFilterOptions}"
|
<ComboBox Grid.Column="0" ItemsSource="{Binding StatusFilterOptions}"
|
||||||
SelectedItem="{Binding StatusFilter}" Margin="0,0,8,0"/>
|
SelectedItem="{Binding StatusFilter}" Margin="0,0,8,0"/>
|
||||||
<ComboBox Grid.Column="1" ItemsSource="{Binding CategoryFilterOptions}"
|
<ComboBox Grid.Column="1" ItemsSource="{Binding CategoryFilterOptions}"
|
||||||
SelectedItem="{Binding CategoryFilter}" Margin="0,0,8,0"/>
|
SelectedItem="{Binding CategoryFilter}" Margin="0,0,8,0"/>
|
||||||
<ComboBox Grid.Column="2" ItemsSource="{Binding GroupFilterOptions}"
|
<ComboBox Grid.Column="2" ItemsSource="{Binding GroupFilterOptions}"
|
||||||
SelectedItem="{Binding GroupFilter}"/>
|
SelectedItem="{Binding GroupFilter}"/>
|
||||||
|
<Button Grid.Column="4" Content="+ Neue Aufgabe" Command="{Binding AddTaskCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="2" Margin="16,0,16,16">
|
<ScrollViewer Grid.Row="1" Margin="16,10,16,16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<ItemsControl ItemsSource="{Binding Tasks}">
|
<ItemsControl ItemsSource="{Binding Tasks}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
@@ -46,11 +36,15 @@
|
|||||||
<Run Text=" · "/>
|
<Run Text=" · "/>
|
||||||
<Run Text="{Binding GroupName}"/>
|
<Run Text="{Binding GroupName}"/>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
<!-- Ist-Zeit vs. Schätzung (6.2.4) -->
|
||||||
|
<TextBlock Text="{Binding ActualVsEstimateDisplay}" FontSize="11" Opacity="0.6"
|
||||||
|
IsVisible="{Binding HasActualTime}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="2" Text="{Binding DueDateDisplay}" VerticalAlignment="Center"
|
<TextBlock Grid.Column="2" Text="{Binding DueDateDisplay}" VerticalAlignment="Center"
|
||||||
Margin="0,0,10,0" FontSize="12" Foreground="{Binding DueDateColorHex}"/>
|
Margin="0,0,10,0" FontSize="12" Foreground="{Binding DueDateColorHex}"/>
|
||||||
<TextBlock Grid.Column="3" Text="{Binding EstimatedMinutesDisplay}"
|
<TextBlock Grid.Column="3" Text="{Binding EstimatedMinutesDisplay}"
|
||||||
VerticalAlignment="Center" Opacity="0.6" FontSize="12" Margin="0,0,10,0"/>
|
VerticalAlignment="Center" Opacity="0.6" FontSize="12" Margin="0,0,10,0"
|
||||||
|
IsVisible="{Binding !HasActualTime}"/>
|
||||||
<Button Grid.Column="4" Content="Bearbeiten" FontSize="11" Padding="8,3" Margin="0,0,4,0"
|
<Button Grid.Column="4" Content="Bearbeiten" FontSize="11" Padding="8,3" Margin="0,0,4,0"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:WorkTaskListViewModel)DataContext).EditTaskCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:WorkTaskListViewModel)DataContext).EditTaskCommand}"
|
||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Workload"
|
||||||
|
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Workload"
|
||||||
|
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Workload.WorkloadView"
|
||||||
|
x:DataType="vm:WorkloadViewModel">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<shared:PageHeader Title="Arbeitszeit" Subtitle="{Binding Tasks.CountSummary}"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
|
||||||
|
<ContentPage Header="Aufgaben">
|
||||||
|
<views:WorkTaskListView DataContext="{Binding Tasks}"/>
|
||||||
|
</ContentPage>
|
||||||
|
<ContentPage Header="Zeiterfassung">
|
||||||
|
<views:TimeTrackingView DataContext="{Binding TimeTracking}"/>
|
||||||
|
</ContentPage>
|
||||||
|
</TabbedPage>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Workload;
|
||||||
|
|
||||||
|
public partial class WorkloadView : UserControl
|
||||||
|
{
|
||||||
|
public WorkloadView() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -778,8 +778,9 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
|
|||||||
## 6. Arbeitszeit & Aufgaben
|
## 6. Arbeitszeit & Aufgaben
|
||||||
|
|
||||||
Modelle `WorkTask` und `TimeEntry` existieren, Repositories ebenfalls.
|
Modelle `WorkTask` und `TimeEntry` existieren, Repositories ebenfalls.
|
||||||
Navigationspunkt "Arbeitszeit" zeigt inzwischen die Aufgabenverwaltung (6.1) statt eines
|
Navigationspunkt "Arbeitszeit" zeigt inzwischen Aufgabenverwaltung (6.1) und Zeiterfassung (6.2)
|
||||||
Placeholders; Zeiterfassung (6.2/6.3) ist noch offen.
|
als zwei Tabs (`WorkloadViewModel`/`WorkloadView`, gleiches Container-Tab-Muster wie
|
||||||
|
`GroupDetailViewModel`); nur die Monats-/Jahresauswertung (6.3) ist noch offen.
|
||||||
|
|
||||||
### 6.1 Aufgabenverwaltung
|
### 6.1 Aufgabenverwaltung
|
||||||
- [x] **6.1.1** Aufgabenliste mit Filter nach Status, Kategorie, Gruppe und Fälligkeit —
|
- [x] **6.1.1** Aufgabenliste mit Filter nach Status, Kategorie, Gruppe und Fälligkeit —
|
||||||
@@ -799,10 +800,23 @@ Placeholders; Zeiterfassung (6.2/6.3) ist noch offen.
|
|||||||
auf `Conducted` (Anbindung an 1.1.3).
|
auf `Conducted` (Anbindung an 1.1.3).
|
||||||
|
|
||||||
### 6.2 Zeiterfassung
|
### 6.2 Zeiterfassung
|
||||||
- [ ] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie.
|
- [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie —
|
||||||
- [ ] **6.2.2** Manuelle Nacherfassung eines Zeitblocks (Datum, Von–Bis oder Dauer).
|
`TimeTrackingViewModel`. Bewusst ohne live mitlaufende Sekundenanzeige (keine
|
||||||
- [ ] **6.2.3** Wochenübersicht der erfassten Zeit, Summen je Kategorie.
|
`DispatcherTimer`-Kopplung in die ViewModel-Schicht); während der Timer läuft, zeigt
|
||||||
- [ ] **6.2.4** Schätzung vs. tatsächliche Dauer je Aufgabe vergleichen.
|
"Läuft seit HH:mm Uhr" den Startzeitpunkt, die Dauer wird beim Stoppen aus der
|
||||||
|
Differenz berechnet (mind. 1 Minute).
|
||||||
|
- [x] **6.2.2** Manuelle Nacherfassung eines Zeitblocks (Datum, Von–Bis oder Dauer) —
|
||||||
|
`AddTimeEntryDialog`: entweder Dauer direkt eintragen oder Von/Bis angeben (daraus wird
|
||||||
|
die Dauer berechnet); mindestens eines von beidem ist Pflicht.
|
||||||
|
- [x] **6.2.3** Wochenübersicht der erfassten Zeit, Summen je Kategorie — Balken je Kategorie
|
||||||
|
(`CategoryWeekSummary`, `TimeEntry.Category` ist bewusst ein freier String statt
|
||||||
|
`TaskCategory`-Enum wie bei `WorkTask`, da das Domänenmodell das schon so vorgab; die
|
||||||
|
ComboBox schlägt trotzdem dieselben Kategorienamen vor). Nur die aktuelle Kalenderwoche,
|
||||||
|
keine Wochennavigation wie im Stundenplan — bei Bedarf später ergänzbar.
|
||||||
|
- [x] **6.2.4** Schätzung vs. tatsächliche Dauer je Aufgabe vergleichen — direkt in der
|
||||||
|
Aufgabenliste (6.1.1) als "X / Y min erfasst", sobald mindestens ein verknüpfter
|
||||||
|
Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
|
||||||
|
`ITimeEntryRepository`.
|
||||||
|
|
||||||
### 6.3 Auswertung
|
### 6.3 Auswertung
|
||||||
- [ ] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle).
|
- [ ] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle).
|
||||||
|
|||||||
Reference in New Issue
Block a user