feat: Pädagogische Klassen-Aufgaben, Prioritäten/Checklisten, Dashboard-Schnellzugriffe

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>
This commit is contained in:
2026-08-21 22:24:38 +02:00
co-authored by Claude Sonnet 5
parent e7c3fdd4ec
commit e65a729f97
25 changed files with 878 additions and 39 deletions
@@ -143,6 +143,8 @@ public interface IWorkTaskRepository
{ {
List<WorkTask> GetByStatus(WorkTaskStatus status); List<WorkTask> GetByStatus(WorkTaskStatus status);
List<WorkTask> GetAll(); List<WorkTask> GetAll();
/// Für das "Anstehende Aufgaben"-Widget im Kurs-Dashboard (9.2/7.2).
List<WorkTask> GetByGroup(Guid groupId);
void Save(WorkTask task); void Save(WorkTask task);
void Delete(Guid id); void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary> /// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
+20
View File
@@ -110,9 +110,28 @@ public class WorkTask
/// TimeEntry verknüpft und fließen deshalb schon rein datenmodell-bedingt (WorkloadEvaluationViewModel /// TimeEntry verknüpft und fließen deshalb schon rein datenmodell-bedingt (WorkloadEvaluationViewModel
/// wertet nur TimeEntry aus, nicht WorkTask) nicht in die Zeitauswertung (6.3) ein. /// wertet nur TimeEntry aus, nicht WorkTask) nicht in die Zeitauswertung (6.3) ein.
public TaskKind Kind { get; set; } = TaskKind.WorkItem; public TaskKind Kind { get; set; } = TaskKind.WorkItem;
/// Nutzer-Feedback: pädagogische Erinnerungen ("Ansage an die Klasse", "Material einsammeln")
/// sollen sich je nach Dringlichkeit farblich absetzen können, unabhängig vom Fälligkeitsdatum.
public TaskPriority Priority { get; set; } = TaskPriority.Normal;
/// Optionale Abhak-Liste (Nutzer-Feedback: "Namensliste aller Kurs-Schüler" ODER freie
/// Teil-Punkte, wahlweise). Beides läuft über denselben Listentyp — bei aus der Kursliste
/// befüllten Einträgen ist StudentId gesetzt (Sprungziel zum Schüler), bei frei eingegebenen
/// Einträgen bleibt es leer. Als verschachtelte LiteDB-Liste statt eigener Collection/JSON-
/// String, siehe TODO.md-Nachtrag zu 6.1/9 — synct dadurch automatisch über den bestehenden
/// Sync-Unterbau mit (EventPublisher serialisiert das ganze WorkTask-Dokument je Save()).
public List<ChecklistItem> ChecklistItems { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
public class ChecklistItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Label { get; set; } = "";
public bool IsDone { get; set; }
/// Gesetzt, wenn der Eintrag aus der Kursliste befüllt wurde (Sprungziel zum Schüler);
/// bei frei eingegebenen Einträgen null.
public Guid? StudentId { get; set; }
}
public class TimeEntry public class TimeEntry
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -130,3 +149,4 @@ public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teach
public enum WorkTaskStatus { Open, InProgress, Done } public enum WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly } public enum TaskRecurrence { None, Weekly, Monthly }
public enum TaskKind { WorkItem, Reminder } public enum TaskKind { WorkItem, Reminder }
public enum TaskPriority { Low, Normal, High }
+41
View File
@@ -954,4 +954,45 @@ public sealed class RepositoryTests
Assert.Single(result); Assert.Single(result);
Assert.Equal("Vertretung 8a", result[0].Description); Assert.Equal("Vertretung 8a", result[0].Description);
} }
// ── WorkTaskRepository ────────────────────────────────────────────────────
[Fact]
public void WorkTaskRepository_GetByGroup_FindetNurAufgabenDieserGruppe()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var groupId = Guid.NewGuid();
repo.Save(new WorkTask { Title = "Klassenaufgabe", GroupId = groupId });
repo.Save(new WorkTask { Title = "Andere Gruppe", GroupId = Guid.NewGuid() });
repo.Save(new WorkTask { Title = "Ohne Gruppe", GroupId = null });
var result = repo.GetByGroup(groupId);
Assert.Single(result);
Assert.Equal("Klassenaufgabe", result[0].Title);
}
[Fact]
public void WorkTaskRepository_SaveUndLoad_RundetPrioritaetUndChecklistItemsRoundTrip()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var studentId = Guid.NewGuid();
var task = new WorkTask
{
Title = "Material einsammeln",
Priority = TaskPriority.High,
ChecklistItems = [new ChecklistItem { Label = "Anna", StudentId = studentId, IsDone = true }],
};
repo.Save(task);
var loaded = repo.GetAll().Single(t => t.Id == task.Id);
Assert.Equal(TaskPriority.High, loaded.Priority);
var item = Assert.Single(loaded.ChecklistItems);
Assert.Equal("Anna", item.Label);
Assert.Equal(studentId, item.StudentId);
Assert.True(item.IsDone);
}
} }
@@ -437,6 +437,8 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList(); db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
public List<WorkTask> GetAll() => public List<WorkTask> GetAll() =>
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList(); db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public List<WorkTask> GetByGroup(Guid groupId) =>
db.Tasks.Find(t => t.GroupId == groupId).OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public void Save(WorkTask t) public void Save(WorkTask t)
{ {
t.UpdatedAt = DateTime.UtcNow; t.UpdatedAt = DateTime.UtcNow;
@@ -462,4 +462,33 @@ public sealed class DashboardViewModelTests
Assert.Equal(student.FullName, item.StudentName); Assert.Equal(student.FullName, item.StudentName);
Assert.Equal(30.0, item.AbsenceRatePercent); Assert.Equal(30.0, item.AbsenceRatePercent);
} }
[Fact]
public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
vm.OnAddTask = startAsReminder => Task.FromResult<WorkTask?>(
new WorkTask { Title = "Ansage an die Klasse", Kind = startAsReminder ? TaskKind.Reminder : TaskKind.WorkItem });
await vm.AddReminderCommand.ExecuteAsync(null);
Assert.Single(tasks.GetAll());
Assert.Contains(vm.OpenTasks, t => t.Title == "Ansage an die Klasse" && t.IsReminder);
}
[Fact]
public async Task AddTask_OhneDelegatMachtNichts()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
await vm.AddTaskCommand.ExecuteAsync(null);
Assert.Empty(tasks.GetAll());
}
} }
+1
View File
@@ -432,6 +432,7 @@ public class FakeWorkTasks : IWorkTaskRepository
public void Add(WorkTask t) => _all.Add(t); public void Add(WorkTask t) => _all.Add(t);
public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList(); public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList();
public List<WorkTask> GetAll() => _all.ToList(); public List<WorkTask> GetAll() => _all.ToList();
public List<WorkTask> GetByGroup(Guid groupId) => _all.Where(t => t.GroupId == groupId).ToList();
public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); } public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id); public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
@@ -19,7 +19,7 @@ public sealed class GroupDetailViewModelTests
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks, var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students, new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), new AttendanceBalanceService(), new SchoolYearService()), new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()), students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
@@ -0,0 +1,80 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback:
/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit,
/// wichtige Todos").
public sealed class GroupListViewModelTests
{
private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null,
FakeExams? exams = null, FakeWorkTasks? tasks = null) =>
new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(),
lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks());
[Fact]
public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick()
{
var group = new LearningGroup { Name = "9c" };
var vm = BuildVm(group);
vm.SelectedGroup = vm.Groups.Count > 0 ? vm.Groups[0] : new GroupListItem(group, "");
Assert.False(vm.QuickHasAnything);
}
[Fact]
public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned });
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
var vm = BuildVm(group, lessons: lessons, exams: exams);
vm.SelectedGroup = new GroupListItem(group, "");
Assert.True(vm.QuickHasNextLesson);
Assert.Contains("Redox", vm.QuickNextLessonLabel);
Assert.True(vm.QuickHasNextExam);
Assert.Contains("Klausur 1", vm.QuickNextExamLabel);
Assert.True(vm.QuickHasAnything);
}
[Fact]
public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
tasks.Add(new WorkTask { GroupId = Guid.NewGuid(), Title = "Andere Gruppe", DueDate = today, Status = WorkTaskStatus.Open });
var vm = BuildVm(group, tasks: tasks);
vm.SelectedGroup = new GroupListItem(group, "");
Assert.True(vm.QuickHasTasks);
Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title);
}
[Fact]
public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
var vm = BuildVm(group, exams: exams);
vm.SelectedGroup = new GroupListItem(group, "");
Assert.True(vm.QuickHasAnything);
vm.SelectedGroup = null;
Assert.False(vm.QuickHasAnything);
Assert.Empty(vm.QuickTasks);
}
}
@@ -12,10 +12,10 @@ public sealed class GroupOverviewViewModelTests
{ {
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null) => FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]), new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
new AttendanceBalanceService(), new SchoolYearService()); tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams, private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario( FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
@@ -301,4 +301,68 @@ public sealed class GroupOverviewViewModelTests
var (vm, _, _, _, _, _, _) = BuildScenario(); var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasMissingHomework); Assert.False(vm.HasMissingHomework);
} }
[Fact]
public void AnstehendeAufgaben_ZeigtNurOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = groupId, Title = "Später fällig", DueDate = today.AddDays(10), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = groupId, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = groupId, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
tasks.Add(new WorkTask { GroupId = otherGroupId, Title = "Anderer Kurs", DueDate = today, Status = WorkTaskStatus.Open });
var vm = NewVm(tasks: tasks);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasGroupTasks);
Assert.Equal(["Bald fällig", "Später fällig"], vm.GroupTasks.Select(t => t.Title));
}
[Fact]
public void AnstehendeAufgaben_OhneAufgabenIstLeer()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasGroupTasks);
}
[Fact]
public void NavigateToWorkload_RuftDelegatAuf()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
var called = false;
vm.OnNavigateToWorkload = () => called = true;
vm.NavigateToWorkloadCommand.Execute(null);
Assert.True(called);
}
[Fact]
public async Task AddGroupTask_SpeichertErgebnisMitGruppeUndAktualisiertListe()
{
var tasks = new FakeWorkTasks();
var (vm, _, _, _, _, _, groupId) = BuildScenario();
var vmWithTasks = NewVm(tasks: tasks);
vmWithTasks.Initialize(groupId, "Testkurs");
vmWithTasks.OnAddGroupTask = requestedGroupId =>
Task.FromResult<WorkTask?>(new WorkTask { Title = "Material einsammeln", GroupId = requestedGroupId });
await vmWithTasks.AddGroupTaskCommand.ExecuteAsync(null);
var saved = Assert.Single(tasks.GetByGroup(groupId));
Assert.Equal("Material einsammeln", saved.Title);
Assert.True(vmWithTasks.HasGroupTasks);
Assert.Equal("Material einsammeln", Assert.Single(vmWithTasks.GroupTasks).Title);
}
[Fact]
public async Task AddGroupTask_OhneDelegatMachtNichts()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
await vm.AddGroupTaskCommand.ExecuteAsync(null);
Assert.False(vm.HasGroupTasks);
}
} }
@@ -337,6 +337,90 @@ public sealed class AddEditWorkTaskDialogViewModelTests
var vm = new AddEditWorkTaskDialogViewModel(source, []); var vm = new AddEditWorkTaskDialogViewModel(source, []);
Assert.True(vm.IsReminder); Assert.True(vm.IsReminder);
} }
[Fact]
public void Save_MitPrioritaet_UebernimmtSieInsResult()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [])
{
Title = "Wichtig",
SelectedPriority = TaskPriorityDisplay.Label(TaskPriority.High),
};
vm.SaveCommand.Execute(null);
Assert.Equal(TaskPriority.High, vm.Result!.Priority);
}
[Fact]
public void Konstruktor_OhneQuelle_StartetMitNormalerPrioritaet()
{
var vm = new AddEditWorkTaskDialogViewModel(null, []);
Assert.Equal(TaskPriorityDisplay.Label(TaskPriority.Normal), vm.SelectedPriority);
}
[Fact]
public void ChecklistItems_HinzufuegenUndEntfernen_WirdInResultUebernommen()
{
var vm = new AddEditWorkTaskDialogViewModel(null, []) { Title = "Material einsammeln" };
vm.NewChecklistItemText = "Erste Position";
vm.AddChecklistItemCommand.Execute(null);
vm.NewChecklistItemText = "Zweite Position";
vm.AddChecklistItemCommand.Execute(null);
Assert.Equal(["Erste Position", "Zweite Position"], vm.ChecklistItems.Select(c => c.Label));
Assert.Equal("", vm.NewChecklistItemText);
vm.RemoveChecklistItemCommand.Execute(vm.ChecklistItems[0]);
vm.SaveCommand.Execute(null);
var item = Assert.Single(vm.Result!.ChecklistItems);
Assert.Equal("Zweite Position", item.Label);
Assert.Null(item.StudentId);
}
[Fact]
public void Konstruktor_BestehendeAufgabeMitChecklist_UebernimmtEintraege()
{
var source = new WorkTask
{
Title = "Alt",
ChecklistItems = [new ChecklistItem { Label = "Schon da", IsDone = true }],
};
var vm = new AddEditWorkTaskDialogViewModel(source, []);
var row = Assert.Single(vm.ChecklistItems);
Assert.Equal("Schon da", row.Label);
Assert.True(row.IsDone);
}
[Fact]
public void FillFromRoster_OhneGruppeOderLader_MachtNichts()
{
var vm = new AddEditWorkTaskDialogViewModel(null, []);
vm.FillFromRosterCommand.Execute(null);
Assert.Empty(vm.ChecklistItems);
}
[Fact]
public void FillFromRoster_BefuelltAusRosterUndUeberspringtDoppelteSchueler()
{
var group = new LearningGroup { Name = "8a" };
var studentId = Guid.NewGuid();
var otherStudentId = Guid.NewGuid();
var vm = new AddEditWorkTaskDialogViewModel(null, [group], loadRoster: _ =>
[(studentId, "Anna Beispiel"), (otherStudentId, "Ben Muster")])
{
SelectedGroup = group,
};
Assert.True(vm.CanFillFromRoster);
vm.FillFromRosterCommand.Execute(null);
Assert.Equal(2, vm.ChecklistItems.Count);
vm.FillFromRosterCommand.Execute(null);
Assert.Equal(2, vm.ChecklistItems.Count); // kein Duplikat beim zweiten Aufruf
}
} }
public sealed class TimeTrackingViewModelTests public sealed class TimeTrackingViewModelTests
@@ -79,6 +79,9 @@ public partial class DashboardViewModel : ObservableObject
// OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten // OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt. // Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
public Action<Guid>? OnNavigateToLesson { get; set; } public Action<Guid>? OnNavigateToLesson { get; set; }
// Direkter Anlege-Einstieg aus der "Offene Aufgaben"-Kachel (Nutzer-Feedback), statt erst über
// "Arbeitszeit" navigieren zu müssen — gleiches Dialog-Delegate-Muster wie im Kurs-Dashboard.
public Func<bool, Task<WorkTask?>>? OnAddTask { get; set; }
public Action<Guid>? OnNavigateToExam { get; set; } public Action<Guid>? OnNavigateToExam { get; set; }
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe // Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann. // (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
@@ -162,7 +165,8 @@ public partial class DashboardViewModel : ObservableObject
OpenTasks.Add(new() { Title = t.Title, OpenTasks.Add(new() { Title = t.Title,
DueDate = t.DueDate?.ToString("dd.MM.") ?? "", DueDate = t.DueDate?.ToString("dd.MM.") ?? "",
IsOverdue = t.DueDate.HasValue && t.DueDate < today, IsOverdue = t.DueDate.HasValue && t.DueDate < today,
IsReminder = t.Kind == TaskKind.Reminder }); IsReminder = t.Kind == TaskKind.Reminder,
IsHighPriority = t.Priority == TaskPriority.High });
CurrentGroups.Clear(); CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name)) foreach (var g in groups.Values.OrderBy(g => g.Name))
@@ -678,6 +682,18 @@ public partial class DashboardViewModel : ObservableObject
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load(); [RelayCommand] private void Refresh() => Load();
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
[RelayCommand] private Task AddReminder() => AddTaskInternal(startAsReminder: true);
private async Task AddTaskInternal(bool startAsReminder)
{
if (OnAddTask is null) return;
var result = await OnAddTask(startAsReminder);
if (result is null) return;
_tasks.Save(result);
Load();
}
private class DayAgg private class DayAgg
{ {
public bool HasLesson; public bool HasLesson;
@@ -698,7 +714,7 @@ public class LessonItem
public string Room { get; set; } = ""; public string Room { get; set; } = "";
public bool HasRoom => !string.IsNullOrWhiteSpace(Room); public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
} }
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } } public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; } public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ──────────────────────── // ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
@@ -4,6 +4,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Workload;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups; namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -36,6 +37,7 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly IParticipationRepository _entries; private readonly IParticipationRepository _entries;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation; private readonly IDocumentationRepository _documentation;
private readonly IWorkTaskRepository _tasks;
private readonly AttendanceBalanceService _attendanceBalance; private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
@@ -69,18 +71,46 @@ public partial class GroupOverviewViewModel : ObservableObject
/// 6 Planung, 7 Kompetenzen, 8 Dokumentation). /// 6 Planung, 7 Kompetenzen, 8 Dokumentation).
public Action<int>? OnNavigateToTab { get; set; } public Action<int>? OnNavigateToTab { get; set; }
/// Anders als OnNavigateToTab kein Tab innerhalb dieser Detailansicht, sondern ein Sprung in
/// den eigenständigen Top-Level-Aufgabenbereich (MainWindowViewModel.NavigateToWorkload) — von
/// hier aus nicht direkt aufrufbar (keine ViewModel-zu-ViewModel-Referenz, siehe
/// GroupDetailView.axaml.cs, gleiches Muster wie LessonViewerDialog/TeachingModeWindow).
public Action? OnNavigateToWorkload { get; set; }
[RelayCommand] private void NavigateToPlanning() => OnNavigateToTab?.Invoke(6); [RelayCommand] private void NavigateToPlanning() => OnNavigateToTab?.Invoke(6);
[RelayCommand] private void NavigateToExams() => OnNavigateToTab?.Invoke(4); [RelayCommand] private void NavigateToExams() => OnNavigateToTab?.Invoke(4);
[RelayCommand] private void NavigateToParticipation() => OnNavigateToTab?.Invoke(3); [RelayCommand] private void NavigateToParticipation() => OnNavigateToTab?.Invoke(3);
[RelayCommand] private void NavigateToDocumentation() => OnNavigateToTab?.Invoke(8); [RelayCommand] private void NavigateToDocumentation() => OnNavigateToTab?.Invoke(8);
[RelayCommand] private void NavigateToWorkload() => OnNavigateToWorkload?.Invoke();
// ── Anstehende Aufgaben für diese Klasse (Nutzer-Feedback: pädagogische Erinnerungen/Aufgaben
// sollen "gleichberechtigt" auch im Kurs-Dashboard stehen, nicht nur im Hauptdashboard) ───────
private const int GroupTasksMaxCount = 5;
public ObservableCollection<GroupTaskItem> GroupTasks { get; } = [];
public bool HasGroupTasks => GroupTasks.Count > 0;
/// Öffnet den Aufgaben-Dialog mit dieser Gruppe vorbelegt (Nutzer-Feedback: direkter
/// Anlege-Einstieg aus dem Kurs-Dashboard, statt erst über "Zu den Aufgaben" springen zu
/// müssen). Gleiches View-Code-Behind-Delegate-Muster wie OnNavigateToWorkload.
public Func<Guid, Task<WorkTask?>>? OnAddGroupTask { get; set; }
[RelayCommand]
private async Task AddGroupTask()
{
if (OnAddGroupTask is null) return;
var result = await OnAddGroupTask(_groupId);
if (result is null) return;
_tasks.Save(result);
LoadGroupTasks(DateOnly.FromDateTime(DateTime.Today));
}
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams, public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
IParticipationSessionRepository sessions, IParticipationRepository entries, IParticipationSessionRepository sessions, IParticipationRepository entries,
IStudentRepository students, IDocumentationRepository documentation, IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks,
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear) AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
{ {
_lessons = lessons; _exams = exams; _sessions = sessions; _lessons = lessons; _exams = exams; _sessions = sessions;
_entries = entries; _students = students; _documentation = documentation; _entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear; _attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
} }
@@ -102,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
LoadDraftDocumentationCount(); LoadDraftDocumentationCount();
LoadAttendanceNotices(today); LoadAttendanceNotices(today);
LoadMissingHomework(); LoadMissingHomework();
LoadGroupTasks(today);
} }
private void LoadNextLesson(DateOnly today) private void LoadNextLesson(DateOnly today)
@@ -260,6 +291,19 @@ public partial class GroupOverviewViewModel : ObservableObject
} }
OnPropertyChanged(nameof(HasMissingHomework)); OnPropertyChanged(nameof(HasMissingHomework));
} }
private void LoadGroupTasks(DateOnly today)
{
GroupTasks.Clear();
foreach (var t in _tasks.GetByGroup(_groupId)
.Where(t => t.Status != WorkTaskStatus.Done)
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(GroupTasksMaxCount))
GroupTasks.Add(new GroupTaskItem(t.Title, t.Kind == TaskKind.Reminder,
t.DueDate?.ToString("dd.MM.yyyy") ?? "",
t.DueDate is { } d && d < today, TaskPriorityDisplay.ColorHex(t.Priority),
t.Priority == TaskPriority.High));
OnPropertyChanged(nameof(HasGroupTasks));
}
} }
public sealed class MissingHomeworkItem(string studentName, string statusLabel) public sealed class MissingHomeworkItem(string studentName, string statusLabel)
@@ -267,3 +311,14 @@ public sealed class MissingHomeworkItem(string studentName, string statusLabel)
public string StudentName { get; } = studentName; public string StudentName { get; } = studentName;
public string StatusLabel { get; } = statusLabel; public string StatusLabel { get; } = statusLabel;
} }
public sealed class GroupTaskItem(string title, bool isReminder, string dueDateDisplay, bool isOverdue,
string priorityColorHex, bool isHighPriority)
{
public string Title { get; } = title;
public bool IsReminder { get; } = isReminder;
public string DueDateDisplay { get; } = dueDateDisplay;
public bool IsOverdue { get; } = isOverdue;
public string PriorityColorHex { get; } = priorityColorHex;
public bool IsHighPriority { get; } = isHighPriority;
}
@@ -4,6 +4,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups; namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -12,8 +13,13 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class GroupListViewModel : ObservableObject public partial class GroupListViewModel : ObservableObject
{ {
private const int QuickTasksMaxCount = 3;
private readonly IGroupRepository _groups; private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects; private readonly ISubjectRepository _subjects;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks;
public Action<Guid, int>? OnNavigateToDetail { get; set; } public Action<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; } public Func<Task>? OnAddGroup { get; set; }
@@ -39,9 +45,22 @@ public partial class GroupListViewModel : ObservableObject
public ObservableCollection<string> SchoolYears { get; } = []; public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = []; public ObservableCollection<GroupListItem> Groups { get; } = [];
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy) // ── Schnellüberblick im Auswahl-Panel (Nutzer-Feedback: "oberhalb der Buttonliste ein paar
// Daten auswerfen. Nächste Stunde, nächste Arbeit, wichtige Todos") — bewusst dieselben
// kompakten Kennzahlen wie die obersten Karten des Kurs-Dashboards (GroupOverviewViewModel),
// hier nur ohne eigenen Tab-Wechsel, da man ohnehin schon auf der Gruppenliste steht.
[ObservableProperty] private bool _quickHasNextLesson;
[ObservableProperty] private string _quickNextLessonLabel = "";
[ObservableProperty] private bool _quickHasNextExam;
[ObservableProperty] private string _quickNextExamLabel = "";
public ObservableCollection<GroupTaskItem> QuickTasks { get; } = [];
public bool QuickHasTasks => QuickTasks.Count > 0;
public bool QuickHasAnything => QuickHasNextLesson || QuickHasNextExam || QuickHasTasks;
public GroupListViewModel(IGroupRepository groups, ISubjectRepository subjects, SchoolYearService sy,
ILessonRepository lessons, IExamRepository exams, IWorkTaskRepository tasks)
{ {
_groups = groups; _subjects = subjects; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y); foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
SelectedSchoolYear = sy.CurrentSchoolYear(); SelectedSchoolYear = sy.CurrentSchoolYear();
} }
@@ -58,6 +77,44 @@ public partial class GroupListViewModel : ObservableObject
RollOverGroupCommand.NotifyCanExecuteChanged(); RollOverGroupCommand.NotifyCanExecuteChanged();
ToggleArchiveCommand.NotifyCanExecuteChanged(); ToggleArchiveCommand.NotifyCanExecuteChanged();
DeleteGroupCommand.NotifyCanExecuteChanged(); DeleteGroupCommand.NotifyCanExecuteChanged();
LoadQuickInfo();
}
private void LoadQuickInfo()
{
QuickTasks.Clear();
if (SelectedGroup is null)
{
QuickHasNextLesson = false; QuickNextLessonLabel = "";
QuickHasNextExam = false; QuickNextExamLabel = "";
OnPropertyChanged(nameof(QuickHasTasks));
OnPropertyChanged(nameof(QuickHasAnything));
return;
}
var groupId = SelectedGroup.Id;
var today = DateOnly.FromDateTime(DateTime.Today);
var nextLesson = _lessons.GetByGroupAndRange(groupId, today, today.AddDays(90))
.Where(l => l.Status == LessonStatus.Planned)
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0).FirstOrDefault();
QuickHasNextLesson = nextLesson is not null;
QuickNextLessonLabel = nextLesson is null ? ""
: string.IsNullOrWhiteSpace(nextLesson.Topic)
? nextLesson.Date.ToString("dd.MM.yyyy")
: $"{nextLesson.Date:dd.MM.yyyy} — {nextLesson.Topic}";
var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= today).MinBy(e => e.Date);
QuickHasNextExam = nextExam is not null;
QuickNextExamLabel = nextExam is null ? "" : $"{nextExam.Date:dd.MM.yyyy} — {nextExam.Title}";
foreach (var t in _tasks.GetByGroup(groupId).Where(t => t.Status != WorkTaskStatus.Done)
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(QuickTasksMaxCount))
QuickTasks.Add(new GroupTaskItem(t.Title, t.Kind == TaskKind.Reminder,
t.DueDate?.ToString("dd.MM.yyyy") ?? "", t.DueDate is { } d && d < today,
TaskPriorityDisplay.ColorHex(t.Priority), t.Priority == TaskPriority.High));
OnPropertyChanged(nameof(QuickHasTasks));
OnPropertyChanged(nameof(QuickHasAnything));
} }
public void LoadGroups() public void LoadGroups()
@@ -168,6 +168,11 @@ public partial class MainWindowViewModel : ObservableObject
} }
public void NavigateToStudents() => NavigateTo(NavItem.Students); public void NavigateToStudents() => NavigateTo(NavItem.Students);
/// Sprungziel aus dem Kurs-Dashboard ("Anstehende Aufgaben für diese Klasse") in den
/// Aufgaben-Bereich — anders als NavigateToGroupDetail/NavigateToSettings kein Tab innerhalb
/// einer Detailansicht, sondern ein eigener Top-Level-Bereich (NavItem.Workload).
public void NavigateToWorkload() => NavigateTo(NavItem.Workload);
} }
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings } public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
@@ -57,6 +57,30 @@ public static class TaskCategoryDisplay
Enum.GetValues<TaskCategory>().FirstOrDefault(c => Label(c) == label, TaskCategory.Other); 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 class TaskRecurrenceDisplay
{ {
public static string Label(TaskRecurrence r) => r switch public static string Label(TaskRecurrence r) => r switch
@@ -218,6 +242,7 @@ public partial class WorkTaskListViewModel : ObservableObject
EstimatedMinutes = item.Model.EstimatedMinutes, EstimatedMinutes = item.Model.EstimatedMinutes,
Recurrence = item.Model.Recurrence, Recurrence = item.Model.Recurrence,
Kind = item.Model.Kind, Kind = item.Model.Kind,
Priority = item.Model.Priority,
Notes = item.Model.Notes, Notes = item.Model.Notes,
Status = WorkTaskStatus.Open, Status = WorkTaskStatus.Open,
}); });
@@ -250,6 +275,17 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut
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" : "";
// 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). // Wiederkehrende Aufgabe (6.1.4).
public bool IsRecurring => Model.Recurrence != TaskRecurrence.None; public bool IsRecurring => Model.Recurrence != TaskRecurrence.None;
public string RecurrenceLabel => $"🔁 {TaskRecurrenceDisplay.Label(Model.Recurrence)}"; public string RecurrenceLabel => $"🔁 {TaskRecurrenceDisplay.Label(Model.Recurrence)}";
@@ -266,6 +302,10 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut
public partial class AddEditWorkTaskDialogViewModel : ObservableObject public partial class AddEditWorkTaskDialogViewModel : ObservableObject
{ {
private readonly WorkTask? _source; 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 _title = "";
[ObservableProperty] private string _selectedCategory = TaskCategoryDisplay.Options[0]; [ObservableProperty] private string _selectedCategory = TaskCategoryDisplay.Options[0];
@@ -273,8 +313,10 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
[ObservableProperty] private string _dueDateText = ""; [ObservableProperty] private string _dueDateText = "";
[ObservableProperty] private string _estimatedMinutesText = ""; [ObservableProperty] private string _estimatedMinutesText = "";
[ObservableProperty] private string _selectedRecurrence = TaskRecurrenceDisplay.Options[0]; [ObservableProperty] private string _selectedRecurrence = TaskRecurrenceDisplay.Options[0];
[ObservableProperty] private string _selectedPriority = TaskPriorityDisplay.Label(TaskPriority.Normal);
[ObservableProperty] private string _notes = ""; [ObservableProperty] private string _notes = "";
[ObservableProperty] private bool _isReminder; [ObservableProperty] private bool _isReminder;
[ObservableProperty] private string _newChecklistItemText = "";
[ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dueDateError = ""; [ObservableProperty] private string _dueDateError = "";
[ObservableProperty] private string _estimatedMinutesError = ""; [ObservableProperty] private string _estimatedMinutesError = "";
@@ -284,15 +326,24 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
: (IsReminder ? "Erinnerung bearbeiten" : "Aufgabe bearbeiten"); : (IsReminder ? "Erinnerung bearbeiten" : "Aufgabe bearbeiten");
public List<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options]; public List<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options]; public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options];
public List<string> PriorityOptions { get; } = [.. TaskPriorityDisplay.Options];
public List<LearningGroup> Groups { get; } public List<LearningGroup> Groups { get; }
public WorkTask? Result { get; private set; } public WorkTask? Result { get; private set; }
partial void OnIsReminderChanged(bool value) => OnPropertyChanged(nameof(DialogTitle)); // 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;
public AddEditWorkTaskDialogViewModel(WorkTask? source, List<LearningGroup> groups, bool startAsReminder = false) 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; _source = source;
Groups = groups; Groups = groups;
_loadRoster = loadRoster;
if (source is null) { IsReminder = startAsReminder; return; } if (source is null) { IsReminder = startAsReminder; return; }
Title = source.Title; Title = source.Title;
@@ -301,8 +352,37 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
DueDateText = source.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? ""; DueDateText = source.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? ""; EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence); SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence);
SelectedPriority = TaskPriorityDisplay.Label(source.Priority);
Notes = source.Notes ?? ""; Notes = source.Notes ?? "";
IsReminder = source.Kind == TaskKind.Reminder; 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] [RelayCommand]
@@ -346,13 +426,39 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
EstimatedMinutes = estimatedMinutes, EstimatedMinutes = estimatedMinutes,
Recurrence = recurrence, Recurrence = recurrence,
Kind = IsReminder ? TaskKind.Reminder : TaskKind.WorkItem, Kind = IsReminder ? TaskKind.Reminder : TaskKind.WorkItem,
Priority = TaskPriorityDisplay.FromLabel(SelectedPriority),
Status = _source?.Status ?? WorkTaskStatus.Open, Status = _source?.Status ?? WorkTaskStatus.Open,
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(), 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, 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) ────────────────────────────────────────────────────── // ── Zeiterfassung (6.2) ──────────────────────────────────────────────────────
public partial class TimeTrackingViewModel : ObservableObject public partial class TimeTrackingViewModel : ObservableObject
@@ -103,18 +103,26 @@
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
<TextBlock Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold" <Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,10">
Opacity="0.5" Margin="0,0,0,10"/> <TextBlock Grid.Column="0" Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="🔔+" FontSize="12" Padding="7,2" Margin="0,0,4,0"
ToolTip.Tip="Erinnerung anlegen" Command="{Binding AddReminderCommand}"/>
<Button Grid.Column="2" Content="" FontSize="12" Padding="8,2"
ToolTip.Tip="Aufgabe anlegen" Command="{Binding AddTaskCommand}"/>
</Grid>
<ItemsControl ItemsSource="{Binding OpenTasks}"> <ItemsControl ItemsSource="{Binding OpenTasks}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskItem"> <DataTemplate DataType="vm:TaskItem">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,3"> <Grid ColumnDefinitions="4,Auto,*,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="🔔" FontSize="12" Margin="0,0,4,0" <Border Grid.Column="0" Background="#D32F2F" CornerRadius="2" Margin="0,0,6,0"
IsVisible="{Binding IsHighPriority}" ToolTip.Tip="Hohe Priorität"/>
<TextBlock Grid.Column="1" Text="🔔" FontSize="12" Margin="0,0,4,0"
IsVisible="{Binding IsReminder}" IsVisible="{Binding IsReminder}"
ToolTip.Tip="Erinnerung — kein Zeitbezug"/> ToolTip.Tip="Erinnerung — kein Zeitbezug"/>
<TextBlock Grid.Column="1" Text="{Binding Title}" <TextBlock Grid.Column="2" Text="{Binding Title}"
FontSize="13" TextTrimming="CharacterEllipsis"/> FontSize="13" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="2" Text="{Binding DueDate}" <TextBlock Grid.Column="3" Text="{Binding DueDate}"
FontSize="12" Opacity="0.6" Margin="8,0,0,0"/> FontSize="12" Opacity="0.6" Margin="8,0,0,0"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
@@ -1,3 +1,25 @@
using Avalonia.Controls; using Avalonia.Controls;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.Views.Workload;
namespace LehrerApp.Desktop.Views.Dashboard; namespace LehrerApp.Desktop.Views.Dashboard;
public partial class DashboardView : UserControl { public DashboardView() => InitializeComponent(); }
public partial class DashboardView : UserControl
{
public DashboardView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is DashboardViewModel vm)
vm.OnAddTask = ShowAddTaskDialog;
}
private async Task<WorkTask?> ShowAddTaskDialog(bool startAsReminder)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
return await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
}
}
@@ -5,11 +5,13 @@ using LehrerApp.Core.Importing;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Views.Shared; using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students; using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups; namespace LehrerApp.Desktop.Views.Groups;
@@ -34,9 +36,19 @@ public partial class GroupDetailView : UserControl
vm.OnGradeExam = ShowGradeExamDialog; vm.OnGradeExam = ShowGradeExamDialog;
vm.OnEvaluateExam = ShowEvaluateExamDialog; vm.OnEvaluateExam = ShowEvaluateExamDialog;
vm.OnConfirmReactivate = ShowReactivateConfirmDialog; vm.OnConfirmReactivate = ShowReactivateConfirmDialog;
vm.OverviewTab.OnNavigateToWorkload = () =>
App.Services.GetRequiredService<MainWindowViewModel>().NavigateToWorkload();
vm.OverviewTab.OnAddGroupTask = ShowAddGroupTaskDialog;
} }
} }
private async Task<WorkTask?> ShowAddGroupTaskDialog(Guid groupId)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
return await WorkTaskDialogHelper.ShowDialog(owner, preselectedGroupId: groupId);
}
private async Task<bool> ShowReactivateConfirmDialog() private async Task<bool> ShowReactivateConfirmDialog()
{ {
var dialog = new ConfirmDialog var dialog = new ConfirmDialog
@@ -5,6 +5,13 @@
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView" x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
x:DataType="vm:GroupListViewModel"> x:DataType="vm:GroupListViewModel">
<UserControl.Styles>
<Style Selector="TextBlock.overdue">
<Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button --> <!-- Kopfzeile mit Schuljahr-Wähler und Neu-Button -->
@@ -108,10 +115,48 @@
<Separator/> <Separator/>
<!-- Schnellüberblick (Nutzer-Feedback) -->
<StackPanel Spacing="8" IsVisible="{Binding QuickHasAnything}">
<TextBlock Text="SCHNELLÜBERBLICK" FontSize="10" FontWeight="Bold" Opacity="0.4"/>
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextLesson}">
<TextBlock Text="Nächste Stunde" FontSize="11" Opacity="0.55"/>
<TextBlock Text="{Binding QuickNextLessonLabel}" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="1" IsVisible="{Binding QuickHasNextExam}">
<TextBlock Text="Nächste Klausur" FontSize="11" Opacity="0.55"/>
<TextBlock Text="{Binding QuickNextExamLabel}" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding QuickHasTasks}">
<TextBlock Text="Wichtige Aufgaben" FontSize="11" Opacity="0.55"/>
<ItemsControl ItemsSource="{Binding QuickTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupTaskItem">
<Grid ColumnDefinitions="4,Auto,*,Auto" Margin="0,2">
<Border Grid.Column="0" Background="{Binding PriorityColorHex}" CornerRadius="2"
Margin="0,0,6,0" IsVisible="{Binding IsHighPriority}"/>
<TextBlock Grid.Column="1" Text="🔔" FontSize="11" Margin="0,0,4,0"
IsVisible="{Binding IsReminder}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Title}" FontSize="12"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding DueDateDisplay}" FontSize="11"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
<Separator IsVisible="{Binding QuickHasAnything}"/>
<!-- Bereichs-Navigation --> <!-- Bereichs-Navigation -->
<TextBlock Text="BEREICHE" FontSize="10" FontWeight="Bold" <TextBlock Text="BEREICHE" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="0,0,0,2"/> Opacity="0.4" Margin="0,0,0,2"/>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<Button Content="📊 Übersicht"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="0"/>
<Button Content="👤 Schülerliste" <Button Content="👤 Schülerliste"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9" Padding="12,9"
@@ -122,6 +167,11 @@
Padding="12,9" Padding="12,9"
Command="{Binding NavigateToSectionCommand}" Command="{Binding NavigateToSectionCommand}"
CommandParameter="2"/> CommandParameter="2"/>
<Button Content="✋ Mitarbeit"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9"
Command="{Binding NavigateToSectionCommand}"
CommandParameter="3"/>
<Button Content="📝 Klausuren" <Button Content="📝 Klausuren"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Padding="12,9" Padding="12,9"
@@ -24,6 +24,10 @@
<Setter Property="Foreground" Value="#D97706"/> <Setter Property="Foreground" Value="#D97706"/>
<Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="FontWeight" Value="SemiBold"/>
</Style> </Style>
<Style Selector="TextBlock.overdue">
<Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Button.cardLink"> <Style Selector="Button.cardLink">
<Setter Property="FontSize" Value="11"/> <Setter Property="FontSize" Value="11"/>
<Setter Property="Padding" Value="0"/> <Setter Property="Padding" Value="0"/>
@@ -165,6 +169,40 @@
<Button Content="Zur Mitarbeit " Classes="cardLink" Command="{Binding NavigateToParticipationCommand}"/> <Button Content="Zur Mitarbeit " Classes="cardLink" Command="{Binding NavigateToParticipationCommand}"/>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Anstehende Aufgaben für diese Klasse (pädagogische Erinnerungen/Aufgaben, Nutzer-
Feedback: "dürfen gerne auch im passenden Kurs-Dashboard stehen") — anders als die
übrigen Karten dieser Reihe bewusst immer sichtbar (statt Has…-gated), da sie zugleich
der Anlege-Einstieg für die erste Aufgabe dieser Gruppe ist. -->
<Border Classes="card" MinWidth="340" MaxWidth="420">
<StackPanel>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="ANSTEHENDE AUFGABEN" Classes="cardTitle"/>
<Button Grid.Column="1" Content="" FontSize="12" Padding="8,2" Margin="0,-4,0,0"
ToolTip.Tip="Aufgabe/Erinnerung für diese Gruppe anlegen"
Command="{Binding AddGroupTaskCommand}"/>
</Grid>
<ItemsControl ItemsSource="{Binding GroupTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupTaskItem">
<Grid ColumnDefinitions="4,Auto,*,Auto" Margin="0,4">
<Border Grid.Column="0" Background="{Binding PriorityColorHex}" CornerRadius="2"
Margin="0,0,6,0" IsVisible="{Binding IsHighPriority}"/>
<TextBlock Grid.Column="1" Text="🔔" FontSize="12" Margin="0,0,4,0"
IsVisible="{Binding IsReminder}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding Title}" FontSize="13"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding DueDateDisplay}" FontSize="12"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine anstehenden Aufgaben für diese Gruppe." Classes="emptyhint"
IsVisible="{Binding !HasGroupTasks}"/>
<Button Content="Zu den Aufgaben " Classes="cardLink" Command="{Binding NavigateToWorkloadCommand}"/>
</StackPanel>
</Border>
</WrapPanel> </WrapPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -57,17 +57,51 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<StackPanel Spacing="4"> <Grid ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wiederholung" FontSize="12" Opacity="0.7" <TextBlock Text="Wiederholung" FontSize="12" Opacity="0.7"
ToolTip.Tip="Beim Abschließen wird automatisch die nächste Instanz mit neuem Fälligkeitsdatum angelegt. Braucht ein Fälligkeitsdatum als Ausgangspunkt."/> ToolTip.Tip="Beim Abschließen wird automatisch die nächste Instanz mit neuem Fälligkeitsdatum angelegt. Braucht ein Fälligkeitsdatum als Ausgangspunkt."/>
<ComboBox ItemsSource="{Binding RecurrenceOptions}" SelectedItem="{Binding SelectedRecurrence}" <ComboBox ItemsSource="{Binding RecurrenceOptions}" SelectedItem="{Binding SelectedRecurrence}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Priorität" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding PriorityOptions}" SelectedItem="{Binding SelectedPriority}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Notizen (optional)" FontSize="12" Opacity="0.7"/> <TextBlock Text="Notizen (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Notes}" AcceptsReturn="True" TextWrapping="Wrap" Height="80"/> <TextBox Text="{Binding Notes}" AcceptsReturn="True" TextWrapping="Wrap" Height="80"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Abhak-Liste (optional)" FontSize="12" Opacity="0.7"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Aus Kursliste befüllen" FontSize="11" Padding="7,3"
Command="{Binding FillFromRosterCommand}" IsVisible="{Binding CanFillFromRoster}"/>
</Grid>
<ItemsControl ItemsSource="{Binding ChecklistItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChecklistItemRow">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,2">
<CheckBox Grid.Column="0" IsChecked="{Binding IsDone}"/>
<TextBlock Grid.Column="1" Text="{Binding Label}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="2" Content="✕" FontSize="11" Padding="6,2"
Command="{Binding $parent[ItemsControl].((vm:AddEditWorkTaskDialogViewModel)DataContext).RemoveChecklistItemCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewChecklistItemText}" PlaceholderText="Eigener Punkt..."/>
<Button Grid.Column="2" Content="+" Command="{Binding AddChecklistItemCommand}"/>
</Grid>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -0,0 +1,41 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Workload;
/// Gemeinsamer Aufruf des Aufgaben-Dialogs für alle drei Entry-Points (Aufgabenliste,
/// Kurs-Dashboard, Hauptdashboard) — vermeidet drei fast identische Kopien der Gruppen-/
/// Kursliste-Ladelogik.
public static class WorkTaskDialogHelper
{
public static async Task<WorkTask?> ShowDialog(Window owner, WorkTask? source = null,
bool startAsReminder = false, Guid? preselectedGroupId = null)
{
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll(includeInactive: true);
var vm = new AddEditWorkTaskDialogViewModel(source, groups, startAsReminder, LoadActiveRoster);
if (source is null && preselectedGroupId is { } groupId)
vm.SelectedGroup = groups.FirstOrDefault(g => g.Id == groupId);
var dialog = new AddEditWorkTaskDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner);
return vm.Result;
}
// "Aus Kursliste befüllen" (Nutzer-Feedback): nur aktuell aktive Mitglieder, wie überall sonst
// im Dashboard/Übersicht-Tab (GroupMembershipService.IsActiveOn).
private static List<(Guid StudentId, string Name)> LoadActiveRoster(Guid groupId)
{
var memberships = App.Services.GetRequiredService<IGroupMembershipRepository>();
var students = App.Services.GetRequiredService<IStudentRepository>();
var today = DateOnly.FromDateTime(DateTime.Today);
return memberships.GetByGroup(groupId)
.Where(m => GroupMembershipService.IsActiveOn(m, today))
.Select(m => students.GetById(m.StudentId))
.Where(s => s is not null)
.Select(s => (s!.Id, s.FullName))
.ToList();
}
}
@@ -25,15 +25,18 @@
<DataTemplate x:DataType="vm:WorkTaskListItem"> <DataTemplate x:DataType="vm:WorkTaskListItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" <Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,10" Margin="0,0,0,8"> CornerRadius="6" Padding="12,10" Margin="0,0,0,8">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto"> <Grid ColumnDefinitions="4,Auto,*,Auto,Auto,Auto,Auto">
<Button Grid.Column="0" Background="{Binding StatusColorHex}" Padding="8,4" <Border Grid.Column="0" Background="{Binding PriorityColorHex}" CornerRadius="2"
Margin="0,0,8,0" IsVisible="{Binding ShowPriority}"
ToolTip.Tip="{Binding PriorityLabel}"/>
<Button Grid.Column="1" Background="{Binding StatusColorHex}" Padding="8,4"
CornerRadius="4" VerticalAlignment="Center" CornerRadius="4" VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:WorkTaskListViewModel)DataContext).CycleStatusCommand}" Command="{Binding $parent[ItemsControl].((vm:WorkTaskListViewModel)DataContext).CycleStatusCommand}"
CommandParameter="{Binding}" CommandParameter="{Binding}"
ToolTip.Tip="Klicken, um den Status zu wechseln"> ToolTip.Tip="Klicken, um den Status zu wechseln">
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/> <TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/>
</Button> </Button>
<StackPanel Grid.Column="1" Margin="10,0" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Margin="10,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="🔔" FontSize="12" IsVisible="{Binding IsReminder}" <TextBlock Text="🔔" FontSize="12" IsVisible="{Binding IsReminder}"
ToolTip.Tip="Erinnerung — kein Zeitbezug, nicht Teil der Auswertung"/> ToolTip.Tip="Erinnerung — kein Zeitbezug, nicht Teil der Auswertung"/>
@@ -50,16 +53,19 @@
<!-- Ist-Zeit vs. Schätzung (6.2.4) --> <!-- Ist-Zeit vs. Schätzung (6.2.4) -->
<TextBlock Text="{Binding ActualVsEstimateDisplay}" FontSize="11" Opacity="0.6" <TextBlock Text="{Binding ActualVsEstimateDisplay}" FontSize="11" Opacity="0.6"
IsVisible="{Binding HasActualTime}"/> IsVisible="{Binding HasActualTime}"/>
<!-- Abhak-Liste -->
<TextBlock Text="{Binding ChecklistProgressDisplay}" FontSize="11" Opacity="0.6"
IsVisible="{Binding HasChecklist}"/>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" Text="{Binding DueDateDisplay}" VerticalAlignment="Center" <TextBlock Grid.Column="3" 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="4" 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}"/> IsVisible="{Binding !HasActualTime}"/>
<Button Grid.Column="4" Content="Bearbeiten" FontSize="11" Padding="8,3" Margin="0,0,4,0" <Button Grid.Column="5" 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}"/>
<Button Grid.Column="5" Content="Löschen" FontSize="11" Padding="8,3" <Button Grid.Column="6" Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:WorkTaskListViewModel)DataContext).DeleteTaskCommand}" Command="{Binding $parent[ItemsControl].((vm:WorkTaskListViewModel)DataContext).DeleteTaskCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
</Grid> </Grid>
@@ -1,8 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Workload; using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Workload; namespace LehrerApp.Desktop.Views.Workload;
@@ -21,11 +19,6 @@ public partial class WorkTaskListView : UserControl
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null; if (owner is null) return null;
return await WorkTaskDialogHelper.ShowDialog(owner, source, startAsReminder);
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll(includeInactive: true);
var vm = new AddEditWorkTaskDialogViewModel(source, groups, startAsReminder);
var dialog = new AddEditWorkTaskDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner);
return vm.Result;
} }
} }
+73
View File
@@ -1436,6 +1436,79 @@ CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus
nur tatsächlich erfasste `TimeEntry`-Zeilen auswertet, keine `WorkTask`-Metadaten. In der nur tatsächlich erfasste `TimeEntry`-Zeilen auswertet, keine `WorkTask`-Metadaten. In der
Aufgabenliste und im Dashboard-Widget "Offene Aufgaben" durch ein 🔔-Symbol gekennzeichnet. Aufgabenliste und im Dashboard-Widget "Offene Aufgaben" durch ein 🔔-Symbol gekennzeichnet.
**Nachtrag — Pädagogische Klassen-Aufgaben (Nutzer-Feedback):** Wunsch nach einer zweiten,
"weniger arbeitszeitrelevant als pädagogisch" gedachten Art von Todo-Item (Beispiele:
"Ansage an die Klasse", "Etwas zum Stichtag einsammeln/austeilen"), gleichberechtigt im
Hauptdashboard **und** im jeweiligen Kurs-Dashboard, farblich nach Frist/Priorität absetzbar,
mit optionaler Abhak-Liste.
Nutzervorschlag war eine eigene `TodoItem`-Basisklasse mit `JobTodo`/`KlassenTodo`-Ableitungen
(Vererbung). Dagegen entschieden: In dieser Codebasis mappt jedes Modell 1:1 auf eine eigene
benannte LiteDB-Collection (`LiteDbContext.cs`, keine `BsonMapper.Entity<T>()`-Discriminator-
Registrierung irgendwo im Projekt) — eine polymorphe Collection wäre der erste Fall dieser Art.
Zusätzlich verwendet die Papierkorb-Wiederherstellung (14.3, `LiteDbContext.MoveToTrash`/
`RestoreFromTrash<T>`) einfaches generisches `JsonSerializer.Serialize<T>`/`Deserialize<T>` ohne
Typ-Diskriminierung — polymorphes Round-Tripping bräuchte dort zusätzliche Handhabung. Stattdessen
wird die "Klassen-Aufgabe" rein über bereits vorhandene Felder abgebildet: `WorkTask.Kind ==
TaskKind.Reminder` (6.1.6, dessen Doku-Kommentar bereits "Ansage an die Klasse" als Beispiel
nennt) kombiniert mit dem längst nullable `WorkTask.GroupId`. Kein neues Modell, keine neue
Collection, kein neuer Dialog nötig — `AddEditWorkTaskDialog` konnte eine solche Aufgabe schon
vorher anlegen.
Neu auf `WorkTask`: `Priority` (`TaskPriority`: Low/Normal/High, Default Normal) und
`ChecklistItems` (`List<ChecklistItem>`, je Eintrag `Label`/`IsDone`/optionale `StudentId`).
Nutzervorschlag war "JSON als DB-String", um keine neue Tabelle zu brauchen und den bestehenden
Sync-Unterbau mitzunutzen — LiteDB verschachtelt C#-Listen/Objekte aber bereits nativ in einem
BSON-Dokument, ein JSON-String-Encoding wäre in einer Dokumenten-DB ein Umweg. Da
`WorkTaskRepository.Save` bei jedem Speichern das komplette `WorkTask`-Dokument über
`db.OnChange` an den Sync-Event-Publisher weiterreicht, syncen neue verschachtelte Felder
automatisch mit, ganz ohne Sync-Layer-Änderung — erreicht damit dasselbe Ziel wie der
JSON-String-Vorschlag, nur ohne Encoding-Zwischenschritt.
`ChecklistItems` vereinheitlicht die zwei vom Nutzer genannten Alternativen (Namensliste aller
Kurs-Schüler *oder* freie Teil-Punkte zum Abhaken) in einem Listentyp: `StudentId` ist gesetzt,
wenn ein Eintrag über "Aus Kursliste befüllen" aus der aktiven Kursmitgliedschaft erzeugt wurde
(`WorkTaskListView.LoadActiveRoster`, dieselbe `GroupMembershipService.IsActiveOn`-Prüfung wie
überall sonst), sonst bleibt es leer für frei eingetippte Punkte — beides im selben
`AddEditWorkTaskDialog`-Editor (Checkbox je Zeile, Entfernen-Button, Freitext-Eingabe).
Farbcodierung: `TaskPriorityDisplay` (Label/ColorHex/Options, gleiches Muster wie
`TaskCategoryDisplay`/`TaskRecurrenceDisplay`) — in der Aufgabenliste als linker Farbbalken nur
bei `High` sichtbar (Normal/Low sollen nicht zusätzlich "schreien"), im Hauptdashboard-Widget
"Offene Aufgaben" ebenso; Fälligkeitsfarbe (überfällig = rot) läuft weiterhin über den
bestehenden `Classes.overdue`-Stil.
Neue Karte "Anstehende Aufgaben" im Kurs-Dashboard (`GroupOverviewViewModel.GroupTasks`, über
neues `IWorkTaskRepository.GetByGroup`) — gleiches Karten-Muster wie die übrigen
Übersicht-Karten (`Has…`-Flag, `ObservableCollection`). Der "Zu den Aufgaben"-Link springt anders
als die übrigen Karten-Links nicht auf einen Tab *innerhalb* der Kurs-Detailansicht (die gibt es
für "Aufgaben" nicht), sondern in den eigenständigen Top-Level-Bereich "Arbeitszeit"
(`NavItem.Workload`) — neue `MainWindowViewModel.NavigateToWorkload()`, verdrahtet in
`GroupDetailView.axaml.cs` über `App.Services`, gleiches Delegate-Muster wie
`LessonViewerDialog`/`TeachingModeWindow` (View-Code-Behind statt ViewModel-zu-ViewModel-Referenz).
**Nachtrag — Direkter Anlege-Einstieg + Schnellüberblick in der Gruppenliste (Folge-Feedback):**
Die "Anstehende Aufgaben"-Karte im Kurs-Dashboard und die "Offene Aufgaben"-Kachel im
Hauptdashboard waren zunächst rein lesend — Anlegen ging nur über den Umweg
"Arbeitszeit". Beide Stellen haben jetzt einen ""-Button, der denselben
`AddEditWorkTaskDialog` öffnet wie die Aufgabenliste selbst (im Kurs-Dashboard mit der
aktuellen Gruppe vorbelegt). Damit das nicht zu drei fast identischen Kopien der
Gruppenlisten-/Kursliste-Ladelogik führt, wurde die Dialog-Öffnung in
`WorkTaskDialogHelper.ShowDialog` (`LehrerApp.Desktop/Views/Workload/`) extrahiert und wird von
`WorkTaskListView`, `GroupDetailView` (`GroupOverviewViewModel.OnAddGroupTask`) und
`DashboardView` (`DashboardViewModel.OnAddTask`) gemeinsam genutzt — gleiches
View-Code-Behind-Delegate-Muster wie überall sonst in dieser Schicht.
Bei der Gelegenheit auch das Auswahl-Panel der Gruppenliste (`GroupListView`) nachgezogen: Beim
Anwählen eines Kurses erschien bisher nur eine reine Sprung-Buttonliste ("BEREICHE") ohne jede
Kennzahl, und der Button für Tab 0 (Übersicht/Kurs-Dashboard, die oben gebaute Karten-Ansicht)
sowie Tab 3 (Mitarbeit) fehlten dort komplett — beide Tabs waren aus diesem Panel gar nicht
erreichbar. Ergänzt: ein "SCHNELLÜBERBLICK"-Block oberhalb der Buttonliste
(`GroupListViewModel.LoadQuickInfo`) mit nächster geplanter Stunde, nächster Klausur und den
(max. 3) nächsten offenen Aufgaben dieser Gruppe — bewusst dieselbe kompakte Auswertung wie die
obersten Karten des Kurs-Dashboards, hier nur ohne eigenen Tab-Wechsel. Sowie die beiden
fehlenden Buttons "📊 Übersicht" (Tab 0) und "✋ Mitarbeit" (Tab 3).
### 6.2 Zeiterfassung ### 6.2 Zeiterfassung
- [x] **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 —
`TimeTrackingViewModel`. Bewusst ohne live mitlaufende Sekundenanzeige (keine `TimeTrackingViewModel`. Bewusst ohne live mitlaufende Sekundenanzeige (keine