Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes
Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht gemachter Hausaufgabe aus der letzten Mitarbeitssitzung. Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard, läuft nicht in die Zeitauswertung ein. Drei Bugfixes aus Nutzer-Feedback: - Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung erzeugen" mit einer Mitarbeitssitzung verknüpft war. - Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an. - Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag eine Lesson existierte, statt schon laut Stundenplan. Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift (Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante ausweichen). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,12 @@ public class WorkTask
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4).
|
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4).
|
||||||
public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None;
|
public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None;
|
||||||
|
/// Nutzer-Feedback: "Aufgaben" wirkt wie reine Zeiterfassung, dabei braucht man auch schlichte
|
||||||
|
/// Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse machen"). Reminder-Aufgaben
|
||||||
|
/// laufen über dieselbe Liste/dasselbe Dashboard-Widget, werden aber nie mit einem
|
||||||
|
/// 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.
|
||||||
|
public TaskKind Kind { get; set; } = TaskKind.WorkItem;
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -112,3 +118,4 @@ public class TimeEntry
|
|||||||
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
|
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
|
||||||
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 }
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ using Xunit;
|
|||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer
|
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer
|
||||||
/// Stunde). Deckt nur die neue Zeit-/Raum-Auflösung ab, nicht die übrigen, unveränderten
|
/// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
|
||||||
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...).
|
/// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
|
||||||
|
/// (offene Aufgaben, ...).
|
||||||
public sealed class DashboardViewModelTests
|
public sealed class DashboardViewModelTests
|
||||||
{
|
{
|
||||||
private static PeriodScheduleService NewPeriodSchedule()
|
private static PeriodScheduleService NewPeriodSchedule()
|
||||||
@@ -41,7 +42,7 @@ public sealed class DashboardViewModelTests
|
|||||||
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
||||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||||
FakeSessions? sessions = null)
|
FakeSessions? sessions = null, FakeEntries? entries = null)
|
||||||
{
|
{
|
||||||
lessons ??= new FakeLessons();
|
lessons ??= new FakeLessons();
|
||||||
lessons.Add(lesson);
|
lessons.Add(lesson);
|
||||||
@@ -49,7 +50,7 @@ public sealed class DashboardViewModelTests
|
|||||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||||
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||||
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), new FakeEntries(),
|
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||||
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||||
@@ -243,6 +244,45 @@ public sealed class DashboardViewModelTests
|
|||||||
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
|
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag()
|
||||||
|
{
|
||||||
|
// Regressionstest (Nutzer-Feedback): "Sitzung erzeugen" (3.3.1) verknüpft die Sitzung über
|
||||||
|
// LessonId mit der Stunde — dieselbe Unterrichtsstunde darf im Kalender nicht zusätzlich
|
||||||
|
// als eigener Sitzungstermin auftauchen.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Brechung" };
|
||||||
|
var session = new ParticipationSession { GroupId = group.Id, Date = today, LessonId = lesson.Id, Comment = "Brechung" };
|
||||||
|
var vm = BuildVm(group, lesson, sessions: new FakeSessions([session]));
|
||||||
|
|
||||||
|
var day = vm.CalendarDays.Single(d => d.Date == today);
|
||||||
|
Assert.True(day.HasLesson);
|
||||||
|
Assert.False(day.HasSession);
|
||||||
|
|
||||||
|
vm.SelectCalendarDayCommand.Execute(day);
|
||||||
|
|
||||||
|
Assert.Single(vm.SelectedDayEvents);
|
||||||
|
Assert.DoesNotContain(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kalender_EigeneKlasseAmStundenplanTagOhneLessonZeigtDenRingTrotzdem()
|
||||||
|
{
|
||||||
|
// Regressionstest (Nutzer-Feedback): der "Meine Klasse"-Ring soll schon laut Stundenplan
|
||||||
|
// gelten, nicht erst, sobald für den Tag eine Lesson angelegt wurde.
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var tomorrow = today.AddDays(1);
|
||||||
|
var group = new LearningGroup { Name = "9c", IsOwnClass = true };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = tomorrow.DayOfWeek, PeriodNumber = 3 });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots);
|
||||||
|
|
||||||
|
var day = vm.CalendarDays.Single(d => d.Date == tomorrow);
|
||||||
|
Assert.True(day.IsOwnClassDay);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
|
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
|
||||||
{
|
{
|
||||||
@@ -373,4 +413,53 @@ public sealed class DashboardViewModelTests
|
|||||||
|
|
||||||
Assert.Empty(vm.UnplannedLessons);
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttendanceWarnings_KleineStichprobeMitHoherQuoteWirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
|
||||||
|
// Nur 3 erfasste Termine, alle unentschuldigt (100 %) — Stichprobe zu klein für eine Meldung.
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
|
||||||
|
sessions.Save(session);
|
||||||
|
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||||
|
|
||||||
|
Assert.Empty(vm.AttendanceWarnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttendanceWarnings_MeldetHoheQuoteAbMindeststichprobe()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
|
||||||
|
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — genug Stichprobe.
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
|
||||||
|
sessions.Save(session);
|
||||||
|
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
|
||||||
|
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||||
|
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||||
|
|
||||||
|
var item = Assert.Single(vm.AttendanceWarnings);
|
||||||
|
Assert.Equal(student.FullName, item.StudentName);
|
||||||
|
Assert.Equal(30.0, item.AbsenceRatePercent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,4 +254,51 @@ public sealed class GroupOverviewViewModelTests
|
|||||||
|
|
||||||
Assert.Equal([6, 4, 3, 8], indices);
|
Assert.Equal([6, 4, 3, 8], indices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FehlendeHausaufgaben_ListetNurSchuelerAusDerLetztenSitzung()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var studentMissing = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var studentDone = new Student { FirstName = "Ben", LastName = "Muster" };
|
||||||
|
var students = new FakeStudents([studentMissing, studentDone]);
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var entries = new FakeEntries();
|
||||||
|
|
||||||
|
var olderSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-10) };
|
||||||
|
sessions.Save(olderSession);
|
||||||
|
entries.Add(new ParticipationEntry
|
||||||
|
{
|
||||||
|
SessionId = olderSession.Id, StudentId = studentDone.Id,
|
||||||
|
Homework = HomeworkStatus.MissingOpen, HomeworkMissing = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var lastSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-1) };
|
||||||
|
sessions.Save(lastSession);
|
||||||
|
entries.Add(new ParticipationEntry
|
||||||
|
{
|
||||||
|
SessionId = lastSession.Id, StudentId = studentMissing.Id,
|
||||||
|
Homework = HomeworkStatus.MissingOverdue, HomeworkMissing = true,
|
||||||
|
});
|
||||||
|
entries.Add(new ParticipationEntry
|
||||||
|
{
|
||||||
|
SessionId = lastSession.Id, StudentId = studentDone.Id,
|
||||||
|
Homework = HomeworkStatus.Completed, HomeworkMissing = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
var vm = NewVm(sessions: sessions, entries: entries, students: students);
|
||||||
|
vm.Initialize(groupId, "Testkurs");
|
||||||
|
|
||||||
|
var item = Assert.Single(vm.MissingHomeworkStudents);
|
||||||
|
Assert.Equal(studentMissing.FullName, item.StudentName);
|
||||||
|
Assert.Contains(lastSession.Date.ToString("dd.MM.yyyy"), vm.MissingHomeworkSessionLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FehlendeHausaufgaben_OhneOffenePostenIstLeer()
|
||||||
|
{
|
||||||
|
var (vm, _, _, _, _, _, _) = BuildScenario();
|
||||||
|
Assert.False(vm.HasMissingHomework);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,4 +284,50 @@ public sealed class SeatingPlanViewModelTests
|
|||||||
Assert.False(vm.CanEdit);
|
Assert.False(vm.CanEdit);
|
||||||
Assert.Empty(sessions.GetByGroup(groupId));
|
Assert.Empty(sessions.GetByGroup(groupId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Initialize_LegtOhneVorhandeneSitzungKeineGeistersitzungAn()
|
||||||
|
{
|
||||||
|
// Regressionstest: Initialize() läuft schon beim bloßen Navigieren zur Kursübersicht
|
||||||
|
// (GroupDetailViewModel.LoadGroup ruft SeatingPlanTab.Initialize für jede Gruppe auf),
|
||||||
|
// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Vorher legte
|
||||||
|
// LoadTodaySessions() dabei sofort eine "Sitzplan"-Sitzung für heute an.
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var vm = new SeatingPlanTabViewModel(
|
||||||
|
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
|
||||||
|
sessions, new FakeEntries(), new FakeAspects());
|
||||||
|
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
|
||||||
|
Assert.Empty(sessions.GetByGroup(groupId));
|
||||||
|
Assert.Null(vm.SelectedSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||||
|
var plan = new SeatingPlan
|
||||||
|
{
|
||||||
|
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||||
|
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||||
|
};
|
||||||
|
var sessions = new FakeSessions([]);
|
||||||
|
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||||
|
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||||
|
sessions, new FakeEntries(), new FakeAspects());
|
||||||
|
vm.Initialize(groupId, isReadOnly: false);
|
||||||
|
vm.OnAssessStudent = _ => Task.CompletedTask;
|
||||||
|
Assert.Empty(sessions.GetByGroup(groupId));
|
||||||
|
|
||||||
|
await vm.AssessStudent(vm.Seats.Single());
|
||||||
|
|
||||||
|
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||||
|
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||||
|
Assert.Equal("Sitzplan", session.Comment);
|
||||||
|
Assert.Equal(session.Id, vm.SelectedSession?.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
{
|
{
|
||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
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);
|
||||||
|
|
||||||
@@ -179,6 +179,26 @@ public sealed class WorkTaskListViewModelTests
|
|||||||
Assert.Equal("Neu", vm.Tasks[0].Title);
|
Assert.Equal("Neu", vm.Tasks[0].Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddReminder_OeffnetDialogVorbelegtAlsErinnerungUndSpeichertErgebnis()
|
||||||
|
{
|
||||||
|
var tasks = new FakeWorkTasks();
|
||||||
|
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
|
||||||
|
bool? passedStartAsReminder = null;
|
||||||
|
vm.OnEditTask = (_, startAsReminder) =>
|
||||||
|
{
|
||||||
|
passedStartAsReminder = startAsReminder;
|
||||||
|
return Task.FromResult<WorkTask?>(new WorkTask { Title = "Ansage machen", Kind = TaskKind.Reminder });
|
||||||
|
};
|
||||||
|
|
||||||
|
await vm.AddReminderCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.True(passedStartAsReminder);
|
||||||
|
var item = Assert.Single(vm.Tasks);
|
||||||
|
Assert.True(item.IsReminder);
|
||||||
|
Assert.Equal("Erinnerung", item.CategoryLabel);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde()
|
public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde()
|
||||||
{
|
{
|
||||||
@@ -285,6 +305,38 @@ public sealed class AddEditWorkTaskDialogViewModelTests
|
|||||||
Assert.NotNull(vm.Result);
|
Assert.NotNull(vm.Result);
|
||||||
Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence);
|
Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_AlsErinnerung_SetztKindUndVerwirftGeschaetzteDauer()
|
||||||
|
{
|
||||||
|
var vm = new AddEditWorkTaskDialogViewModel(null, [])
|
||||||
|
{
|
||||||
|
Title = "Ansage an die Klasse machen",
|
||||||
|
IsReminder = true,
|
||||||
|
EstimatedMinutesText = "90", // sollte ignoriert werden
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.Result);
|
||||||
|
Assert.Equal(TaskKind.Reminder, vm.Result!.Kind);
|
||||||
|
Assert.Null(vm.Result.EstimatedMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Konstruktor_StartAsReminder_SetztIsReminderVorbelegt()
|
||||||
|
{
|
||||||
|
var vm = new AddEditWorkTaskDialogViewModel(null, [], startAsReminder: true);
|
||||||
|
Assert.True(vm.IsReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Konstruktor_BestehendeErinnerung_ErkenntKindWieder()
|
||||||
|
{
|
||||||
|
var source = new WorkTask { Title = "Alt", Kind = TaskKind.Reminder };
|
||||||
|
var vm = new AddEditWorkTaskDialogViewModel(source, []);
|
||||||
|
Assert.True(vm.IsReminder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TimeTrackingViewModelTests
|
public sealed class TimeTrackingViewModelTests
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
|
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
|
||||||
/// um sich abends noch vorzubereiten.
|
/// um sich abends noch vorzubereiten.
|
||||||
private const int UnplannedLessonsLookaheadDays = 1;
|
private const int UnplannedLessonsLookaheadDays = 1;
|
||||||
|
/// Nutzer-Feedback: unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden
|
||||||
|
/// Schuljahr wird eine hohe Fehlquote NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt,
|
||||||
|
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
||||||
|
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
|
||||||
|
private const int AttendanceMinSampleSize = 8;
|
||||||
|
|
||||||
[ObservableProperty] private string _greeting = "";
|
[ObservableProperty] private string _greeting = "";
|
||||||
[ObservableProperty] private string _currentDate = "";
|
[ObservableProperty] private string _currentDate = "";
|
||||||
@@ -156,7 +161,8 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5))
|
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5))
|
||||||
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 });
|
||||||
|
|
||||||
CurrentGroups.Clear();
|
CurrentGroups.Clear();
|
||||||
foreach (var g in groups.Values.OrderBy(g => g.Name))
|
foreach (var g in groups.Values.OrderBy(g => g.Name))
|
||||||
@@ -197,7 +203,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
.Select(t => (t.Item1!.Value, t.Attendance));
|
.Select(t => (t.Item1!.Value, t.Attendance));
|
||||||
|
|
||||||
var balance = _attendanceBalance.Calculate(entries, from, to);
|
var balance = _attendanceBalance.Calculate(entries, from, to);
|
||||||
if (balance.ExceedsThreshold)
|
if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold)
|
||||||
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
||||||
}
|
}
|
||||||
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
||||||
@@ -555,8 +561,12 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
exam.Title, g.Name, g.Id));
|
exam.Title, g.Name, g.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sitzungen, die über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstanden sind
|
||||||
|
// (LessonId gesetzt), bekommen bewusst keinen eigenen Kalendereintrag — die Stunde
|
||||||
|
// selbst ist an diesem Tag/dieser Gruppe schon als Lesson-Termin gelistet, ein
|
||||||
|
// zweiter Eintrag für dieselbe Unterrichtsstunde wäre eine Dopplung.
|
||||||
foreach (var session in _participationSessions.GetByGroup(g.Id)
|
foreach (var session in _participationSessions.GetByGroup(g.Id)
|
||||||
.Where(s => s.Date >= gridStart && s.Date <= gridEnd))
|
.Where(s => s.LessonId is null && s.Date >= gridStart && s.Date <= gridEnd))
|
||||||
{
|
{
|
||||||
var agg = Agg(session.Date);
|
var agg = Agg(session.Date);
|
||||||
agg.HasSession = true;
|
agg.HasSession = true;
|
||||||
@@ -566,6 +576,35 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
|
||||||
|
// existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den
|
||||||
|
// aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht.
|
||||||
|
// Dieselbe Projektion wie bei den ungeplanten Stunden (LoadUnplannedLessons), nur über den
|
||||||
|
// gesamten Kalenderraster statt nur die nächsten Tage.
|
||||||
|
var publicHolidayDatesForGrid = Enumerable.Range(gridStart.Year, gridEnd.Year - gridStart.Year + 1)
|
||||||
|
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||||
|
.Select(h => h.Date).ToHashSet();
|
||||||
|
var schoolHolidaysForGrid = _schoolHolidays.GetAll();
|
||||||
|
foreach (var g in groups.Where(g => g.IsOwnClass))
|
||||||
|
{
|
||||||
|
var slots = _timetableSlots.GetByGroup(g.Id);
|
||||||
|
if (slots.Count == 0) continue;
|
||||||
|
|
||||||
|
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
if (IsFreeDay(date, schoolHolidaysForGrid, publicHolidayDatesForGrid)) continue;
|
||||||
|
var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||||
|
if (daySlots.Count == 0) continue;
|
||||||
|
|
||||||
|
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||||
|
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||||
|
.Select(s => s.PeriodNumber).ToHashSet();
|
||||||
|
if (daySlots.All(s => cancelledPeriods.Contains(s.PeriodNumber))) continue;
|
||||||
|
|
||||||
|
Agg(date).IsOwnClassDay = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < 42; i++)
|
for (var i = 0; i < 42; i++)
|
||||||
{
|
{
|
||||||
var date = gridStart.AddDays(i);
|
var date = gridStart.AddDays(i);
|
||||||
@@ -659,7 +698,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 class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { 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) ────────────────────────
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
/// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine
|
/// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine
|
||||||
/// hohe Fehlquote bewusst NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rein
|
/// hohe Fehlquote bewusst NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rein
|
||||||
/// rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
/// rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
|
||||||
/// Stichprobe. Dasselbe Problem hat aktuell noch die Fehlzeiten-Warnung im Hauptdashboard
|
/// Stichprobe. Dieselbe Konstante wie DashboardViewModel.AttendanceMinSampleSize
|
||||||
/// (AttendanceBalanceService/AttendanceWarningItem) — bewusst unangetastet, wird dort separat
|
/// (Hauptdashboard-Fehlzeiten-Warnung, 5.2.3) — dort dasselbe Problem, mittlerweile ebenfalls
|
||||||
/// nachgezogen.
|
/// gefixt.
|
||||||
private const int AttendanceMinSampleSize = 8;
|
private const int AttendanceMinSampleSize = 8;
|
||||||
|
|
||||||
private readonly ILessonRepository _lessons;
|
private readonly ILessonRepository _lessons;
|
||||||
@@ -55,6 +55,10 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
||||||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _missingHomeworkSessionLabel = "";
|
||||||
|
public ObservableCollection<MissingHomeworkItem> MissingHomeworkStudents { get; } = [];
|
||||||
|
public bool HasMissingHomework => MissingHomeworkStudents.Count > 0;
|
||||||
|
|
||||||
/// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
|
/// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
|
||||||
/// siehe <see cref="AttendanceMinSampleSize"/>.
|
/// siehe <see cref="AttendanceMinSampleSize"/>.
|
||||||
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
|
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
|
||||||
@@ -97,6 +101,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
LoadOpenExcuses(today);
|
LoadOpenExcuses(today);
|
||||||
LoadDraftDocumentationCount();
|
LoadDraftDocumentationCount();
|
||||||
LoadAttendanceNotices(today);
|
LoadAttendanceNotices(today);
|
||||||
|
LoadMissingHomework();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadNextLesson(DateOnly today)
|
private void LoadNextLesson(DateOnly today)
|
||||||
@@ -200,10 +205,9 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
.Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
|
.Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bewusst kein 1:1-Abbild der Dashboard-Fehlzeiten-Warnung: dieselbe Quote
|
/// Dieselbe Mindeststichprobe wie DashboardViewModel.LoadAttendanceWarnings
|
||||||
/// (AttendanceBalanceService.WarningThresholdPercent), aber erst ab
|
/// (AttendanceMinSampleSize) — erst ab genug erfassten Terminen wird eine hohe Quote gemeldet,
|
||||||
/// <see cref="AttendanceMinSampleSize"/> erfassten Terminen, damit ein Fehltag in der ersten
|
/// damit ein Fehltag in der ersten Schulwoche nicht sofort als auffällig gilt.
|
||||||
/// Schulwoche nicht sofort als auffällig gilt.
|
|
||||||
private void LoadAttendanceNotices(DateOnly today)
|
private void LoadAttendanceNotices(DateOnly today)
|
||||||
{
|
{
|
||||||
AttendanceNotices.Clear();
|
AttendanceNotices.Clear();
|
||||||
@@ -225,15 +229,41 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
var items = new List<AttendanceWarningItem>();
|
var items = new List<AttendanceWarningItem>();
|
||||||
foreach (var student in _students.GetByGroup(_groupId))
|
foreach (var student in _students.GetByGroup(_groupId))
|
||||||
{
|
{
|
||||||
if (!entriesByStudent.TryGetValue(student.Id, out var entries) || entries.Count < AttendanceMinSampleSize)
|
if (!entriesByStudent.TryGetValue(student.Id, out var entries)) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
var balance = _attendanceBalance.Calculate(entries, from, to);
|
var balance = _attendanceBalance.Calculate(entries, from, to);
|
||||||
if (balance.ExceedsThreshold)
|
if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold)
|
||||||
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
|
||||||
}
|
}
|
||||||
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
||||||
AttendanceNotices.Add(item);
|
AttendanceNotices.Add(item);
|
||||||
OnPropertyChanged(nameof(HasAttendanceNotices));
|
OnPropertyChanged(nameof(HasAttendanceNotices));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schüler mit nicht gemachter Hausaufgabe aus der letzten Mitarbeitssitzung
|
||||||
|
/// (ParticipationEntry.HomeworkMissing, gepflegt über HomeworkDisplay.CountsAsMissing) —
|
||||||
|
/// bewusst nur die letzte Sitzung, nicht die gesamte Historie, analog zur
|
||||||
|
/// Hausaufgaben-Kontrolle in <see cref="LoadOpenHomeworkCheck"/>.
|
||||||
|
private void LoadMissingHomework()
|
||||||
|
{
|
||||||
|
MissingHomeworkStudents.Clear();
|
||||||
|
var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault();
|
||||||
|
if (last is not null)
|
||||||
|
{
|
||||||
|
MissingHomeworkSessionLabel = $"Aus der Sitzung vom {last.Date:dd.MM.yyyy}:";
|
||||||
|
foreach (var entry in _entries.GetBySession(last.Id).Where(e => e.HomeworkMissing))
|
||||||
|
{
|
||||||
|
var student = _students.GetById(entry.StudentId);
|
||||||
|
if (student is not null)
|
||||||
|
MissingHomeworkStudents.Add(new MissingHomeworkItem(student.FullName, HomeworkDisplay.Label(entry.Homework)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(HasMissingHomework));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MissingHomeworkItem(string studentName, string statusLabel)
|
||||||
|
{
|
||||||
|
public string StudentName { get; } = studentName;
|
||||||
|
public string StatusLabel { get; } = statusLabel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,22 +76,39 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
NotifyCommands();
|
NotifyCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legt bewusst KEINE Sitzung an, nur weil der Tab geöffnet wird — Initialize() läuft für
|
||||||
|
/// jede Gruppe schon beim bloßen Navigieren zur Kursübersicht (GroupDetailViewModel.LoadGroup),
|
||||||
|
/// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Eine Sitzung entsteht erst
|
||||||
|
/// bei der ersten tatsächlichen Bewertung/Markierung, siehe EnsureTodaySession().
|
||||||
private void LoadTodaySessions()
|
private void LoadTodaySessions()
|
||||||
{
|
{
|
||||||
TodaySessions.Clear();
|
TodaySessions.Clear();
|
||||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today)
|
var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today)
|
||||||
.OrderBy(s => s.CreatedAt).ToList();
|
.OrderBy(s => s.CreatedAt).ToList();
|
||||||
if (sessions.Count == 0 && !_isReadOnly)
|
|
||||||
{
|
|
||||||
var created = new ParticipationSession { GroupId = _groupId, Date = today, Comment = "Sitzplan" };
|
|
||||||
_sessions.Save(created);
|
|
||||||
sessions.Add(created);
|
|
||||||
}
|
|
||||||
foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session));
|
foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session));
|
||||||
SelectedSession = TodaySessions.LastOrDefault();
|
SelectedSession = TodaySessions.LastOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen
|
||||||
|
/// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/
|
||||||
|
/// ToggleSituationTag), nicht schon beim Öffnen des Tabs.
|
||||||
|
private ParticipationSessionOption? EnsureTodaySession()
|
||||||
|
{
|
||||||
|
if (SelectedSession is not null) return SelectedSession;
|
||||||
|
if (!IsEditable) return null;
|
||||||
|
|
||||||
|
var created = new ParticipationSession
|
||||||
|
{
|
||||||
|
GroupId = _groupId, Date = DateOnly.FromDateTime(DateTime.Today), Comment = "Sitzplan",
|
||||||
|
};
|
||||||
|
_sessions.Save(created);
|
||||||
|
var option = new ParticipationSessionOption(created);
|
||||||
|
TodaySessions.Add(option);
|
||||||
|
SelectedSession = option;
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData();
|
partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData();
|
||||||
|
|
||||||
private void LoadStudentOptions()
|
private void LoadStudentOptions()
|
||||||
@@ -193,19 +210,21 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
|
|
||||||
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
|
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
|
||||||
{
|
{
|
||||||
if (!IsEditable || _documentation is null || SelectedSession is null ||
|
if (!IsEditable || _documentation is null ||
|
||||||
seat.SelectedOption.StudentId is not Guid studentId) return;
|
seat.SelectedOption.StudentId is not Guid studentId) return;
|
||||||
|
var session = EnsureTodaySession();
|
||||||
|
if (session is null) return;
|
||||||
var draft = _documentation.GetByStudent(studentId)
|
var draft = _documentation.GetByStudent(studentId)
|
||||||
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id);
|
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == session.Id);
|
||||||
if (draft is null)
|
if (draft is null)
|
||||||
{
|
{
|
||||||
draft = new Documentation
|
draft = new Documentation
|
||||||
{
|
{
|
||||||
StudentId = studentId, GroupId = _groupId,
|
StudentId = studentId, GroupId = _groupId,
|
||||||
ParticipationSessionId = SelectedSession.Id,
|
ParticipationSessionId = session.Id,
|
||||||
LessonId = SelectedSession.LessonId,
|
LessonId = session.LessonId,
|
||||||
Type = DocumentationType.Incident,
|
Type = DocumentationType.Incident,
|
||||||
Date = SelectedSession.Date,
|
Date = session.Date,
|
||||||
Title = tag,
|
Title = tag,
|
||||||
IsDraft = true,
|
IsDraft = true,
|
||||||
Tags = [tag],
|
Tags = [tag],
|
||||||
@@ -297,9 +316,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
|||||||
public async Task AssessStudent(SeatCellViewModel seat)
|
public async Task AssessStudent(SeatCellViewModel seat)
|
||||||
{
|
{
|
||||||
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
|
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
|
||||||
|
var session = EnsureTodaySession();
|
||||||
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
|
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
|
||||||
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
|
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
|
||||||
SelectedSession?.Id);
|
session?.Id);
|
||||||
await OnAssessStudent(assessment);
|
await OnAssessStudent(assessment);
|
||||||
RefreshSeatLessonData();
|
RefreshSeatLessonData();
|
||||||
OnAssessmentChanged?.Invoke();
|
OnAssessmentChanged?.Invoke();
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ public partial class WorkTaskListViewModel : ObservableObject
|
|||||||
public ObservableCollection<WorkTaskListItem> Tasks { get; } = [];
|
public ObservableCollection<WorkTaskListItem> Tasks { get; } = [];
|
||||||
public string CountSummary => $"{Tasks.Count} Aufgabe(n)";
|
public string CountSummary => $"{Tasks.Count} Aufgabe(n)";
|
||||||
|
|
||||||
public Func<WorkTask?, Task<WorkTask?>>? OnEditTask { get; set; }
|
/// Zweiter Parameter = Dialog startet vorbelegt als Erinnerung (nur bei AddReminder true).
|
||||||
|
public Func<WorkTask?, bool, Task<WorkTask?>>? OnEditTask { get; set; }
|
||||||
|
|
||||||
private Dictionary<Guid, string> _groupNames = [];
|
private Dictionary<Guid, string> _groupNames = [];
|
||||||
|
|
||||||
@@ -166,7 +167,20 @@ public partial class WorkTaskListViewModel : ObservableObject
|
|||||||
private async Task AddTask()
|
private async Task AddTask()
|
||||||
{
|
{
|
||||||
if (OnEditTask is null) return;
|
if (OnEditTask is null) return;
|
||||||
var result = await OnEditTask(null);
|
var result = await OnEditTask(null, false);
|
||||||
|
if (result is null) return;
|
||||||
|
_tasks.Save(result);
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigener Einstieg statt eines Umschalters im selben Dialog-Aufruf wie AddTask (6.1.1
|
||||||
|
/// Nutzer-Feedback): "Erinnerung" soll sich nicht wie eine Variante der Zeiterfassung anfühlen,
|
||||||
|
/// sondern wie eine eigene, schnelle Aktion.
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddReminder()
|
||||||
|
{
|
||||||
|
if (OnEditTask is null) return;
|
||||||
|
var result = await OnEditTask(null, true);
|
||||||
if (result is null) return;
|
if (result is null) return;
|
||||||
_tasks.Save(result);
|
_tasks.Save(result);
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -176,7 +190,7 @@ public partial class WorkTaskListViewModel : ObservableObject
|
|||||||
private async Task EditTask(WorkTaskListItem? item)
|
private async Task EditTask(WorkTaskListItem? item)
|
||||||
{
|
{
|
||||||
if (item is null || OnEditTask is null) return;
|
if (item is null || OnEditTask is null) return;
|
||||||
var result = await OnEditTask(item.Model);
|
var result = await OnEditTask(item.Model, false);
|
||||||
if (result is null) return;
|
if (result is null) return;
|
||||||
_tasks.Save(result);
|
_tasks.Save(result);
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -203,6 +217,7 @@ public partial class WorkTaskListViewModel : ObservableObject
|
|||||||
? dueDate.AddDays(7) : dueDate.AddMonths(1),
|
? dueDate.AddDays(7) : dueDate.AddMonths(1),
|
||||||
EstimatedMinutes = item.Model.EstimatedMinutes,
|
EstimatedMinutes = item.Model.EstimatedMinutes,
|
||||||
Recurrence = item.Model.Recurrence,
|
Recurrence = item.Model.Recurrence,
|
||||||
|
Kind = item.Model.Kind,
|
||||||
Notes = item.Model.Notes,
|
Notes = item.Model.Notes,
|
||||||
Status = WorkTaskStatus.Open,
|
Status = WorkTaskStatus.Open,
|
||||||
});
|
});
|
||||||
@@ -224,7 +239,8 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut
|
|||||||
{
|
{
|
||||||
public WorkTask Model { get; } = model;
|
public WorkTask Model { get; } = model;
|
||||||
public string Title => Model.Title;
|
public string Title => Model.Title;
|
||||||
public string CategoryLabel => TaskCategoryDisplay.Label(Model.Category);
|
public bool IsReminder => Model.Kind == TaskKind.Reminder;
|
||||||
|
public string CategoryLabel => IsReminder ? "Erinnerung" : TaskCategoryDisplay.Label(Model.Category);
|
||||||
public string GroupName => groupName ?? "";
|
public string GroupName => groupName ?? "";
|
||||||
public string DueDateDisplay => Model.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
|
public string DueDateDisplay => Model.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
|
||||||
public bool IsOverdue => Model.DueDate is { } d && d < DateOnly.FromDateTime(DateTime.Today)
|
public bool IsOverdue => Model.DueDate is { } d && d < DateOnly.FromDateTime(DateTime.Today)
|
||||||
@@ -258,21 +274,26 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
|
|||||||
[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 _notes = "";
|
[ObservableProperty] private string _notes = "";
|
||||||
|
[ObservableProperty] private bool _isReminder;
|
||||||
[ObservableProperty] private string _titleError = "";
|
[ObservableProperty] private string _titleError = "";
|
||||||
[ObservableProperty] private string _dueDateError = "";
|
[ObservableProperty] private string _dueDateError = "";
|
||||||
[ObservableProperty] private string _estimatedMinutesError = "";
|
[ObservableProperty] private string _estimatedMinutesError = "";
|
||||||
|
|
||||||
public string DialogTitle => _source is null ? "Aufgabe anlegen" : "Aufgabe bearbeiten";
|
public string DialogTitle => _source is null
|
||||||
|
? (IsReminder ? "Erinnerung anlegen" : "Aufgabe anlegen")
|
||||||
|
: (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<LearningGroup> Groups { get; }
|
public List<LearningGroup> Groups { get; }
|
||||||
public WorkTask? Result { get; private set; }
|
public WorkTask? Result { get; private set; }
|
||||||
|
|
||||||
public AddEditWorkTaskDialogViewModel(WorkTask? source, List<LearningGroup> groups)
|
partial void OnIsReminderChanged(bool value) => OnPropertyChanged(nameof(DialogTitle));
|
||||||
|
|
||||||
|
public AddEditWorkTaskDialogViewModel(WorkTask? source, List<LearningGroup> groups, bool startAsReminder = false)
|
||||||
{
|
{
|
||||||
_source = source;
|
_source = source;
|
||||||
Groups = groups;
|
Groups = groups;
|
||||||
if (source is null) return;
|
if (source is null) { IsReminder = startAsReminder; return; }
|
||||||
|
|
||||||
Title = source.Title;
|
Title = source.Title;
|
||||||
SelectedCategory = TaskCategoryDisplay.Label(source.Category);
|
SelectedCategory = TaskCategoryDisplay.Label(source.Category);
|
||||||
@@ -281,6 +302,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
|
|||||||
EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
|
EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||||
SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence);
|
SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence);
|
||||||
Notes = source.Notes ?? "";
|
Notes = source.Notes ?? "";
|
||||||
|
IsReminder = source.Kind == TaskKind.Reminder;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -303,8 +325,9 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
|
|||||||
if (recurrence != TaskRecurrence.None && dueDate is null)
|
if (recurrence != TaskRecurrence.None && dueDate is null)
|
||||||
{ DueDateError = "Fälligkeitsdatum erforderlich, damit die nächste Instanz geplant werden kann."; valid = false; }
|
{ DueDateError = "Fälligkeitsdatum erforderlich, damit die nächste Instanz geplant werden kann."; valid = false; }
|
||||||
|
|
||||||
|
// Erinnerungen haben bewusst keinen Zeitbezug — die Minutenschätzung entfällt.
|
||||||
int? estimatedMinutes = null;
|
int? estimatedMinutes = null;
|
||||||
if (!string.IsNullOrWhiteSpace(EstimatedMinutesText))
|
if (!IsReminder && !string.IsNullOrWhiteSpace(EstimatedMinutesText))
|
||||||
{
|
{
|
||||||
if (!int.TryParse(EstimatedMinutesText, out var m) || m <= 0)
|
if (!int.TryParse(EstimatedMinutesText, out var m) || m <= 0)
|
||||||
{ EstimatedMinutesError = "Ganze Zahl > 0 erwartet."; valid = false; }
|
{ EstimatedMinutesError = "Ganze Zahl > 0 erwartet."; valid = false; }
|
||||||
@@ -322,6 +345,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
|
|||||||
DueDate = dueDate,
|
DueDate = dueDate,
|
||||||
EstimatedMinutes = estimatedMinutes,
|
EstimatedMinutes = estimatedMinutes,
|
||||||
Recurrence = recurrence,
|
Recurrence = recurrence,
|
||||||
|
Kind = IsReminder ? TaskKind.Reminder : TaskKind.WorkItem,
|
||||||
Status = _source?.Status ?? WorkTaskStatus.Open,
|
Status = _source?.Status ?? WorkTaskStatus.Open,
|
||||||
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
|
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
|
||||||
CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow,
|
CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow,
|
||||||
|
|||||||
@@ -108,10 +108,13 @@
|
|||||||
<ItemsControl ItemsSource="{Binding OpenTasks}">
|
<ItemsControl ItemsSource="{Binding OpenTasks}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:TaskItem">
|
<DataTemplate DataType="vm:TaskItem">
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,3">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
<TextBlock Grid.Column="0" Text="🔔" FontSize="12" Margin="0,0,4,0"
|
||||||
|
IsVisible="{Binding IsReminder}"
|
||||||
|
ToolTip.Tip="Erinnerung — kein Zeitbezug"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding Title}"
|
||||||
FontSize="13" TextTrimming="CharacterEllipsis"/>
|
FontSize="13" TextTrimming="CharacterEllipsis"/>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding DueDate}"
|
<TextBlock Grid.Column="2" 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>
|
||||||
|
|||||||
@@ -143,6 +143,28 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Fehlende Hausaufgaben aus der letzten Sitzung -->
|
||||||
|
<Border Classes="card" MinWidth="340" MaxWidth="420"
|
||||||
|
IsVisible="{Binding HasMissingHomework}">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="FEHLENDE HAUSAUFGABEN" Classes="cardTitle"/>
|
||||||
|
<TextBlock Text="{Binding MissingHomeworkSessionLabel}" FontSize="11" Opacity="0.6" Margin="0,-6,0,6"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding MissingHomeworkStudents}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MissingHomeworkItem">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding StudentName}" FontSize="13"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding StatusLabel}" FontSize="11"
|
||||||
|
Opacity="0.7" VerticalAlignment="Center" TextAlignment="Right"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Button Content="Zur Mitarbeit ›" Classes="cardLink" Command="{Binding NavigateToParticipationCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|||||||
@@ -67,10 +67,14 @@
|
|||||||
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
|
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
|
||||||
<DockPanel.Styles>
|
<DockPanel.Styles>
|
||||||
<!-- Basisgröße der Icons als Style (nicht lokal), damit der Compact-Selector sie
|
<!-- Basisgröße der Icons als Style (nicht lokal), damit der Compact-Selector sie
|
||||||
überschreiben kann – lokale Werte hätten immer Vorrang vor Style-Settern. -->
|
überschreiben kann – lokale Werte hätten immer Vorrang vor Style-Settern.
|
||||||
|
FontFamily explizit auf die farbige Emoji-Schrift gepinnt: Windows kann für
|
||||||
|
Emoji-Codepoints je nach Fallback-Auflösung sonst auf "Segoe UI Symbol"
|
||||||
|
(einfarbig/schwarz) statt "Segoe UI Emoji" (farbig) ausweichen. -->
|
||||||
<Style Selector="TextBlock.navicon">
|
<Style Selector="TextBlock.navicon">
|
||||||
<Setter Property="FontSize" Value="15"/>
|
<Setter Property="FontSize" Value="15"/>
|
||||||
<Setter Property="Width" Value="24"/>
|
<Setter Property="Width" Value="24"/>
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.navitem">
|
<Style Selector="Button.navitem">
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<CheckBox Content="🔔 Nur Erinnerung (kein Zeitbezug, läuft nicht in die Zeiterfassung ein)"
|
||||||
|
IsChecked="{Binding IsReminder}"/>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,*">
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Kategorie *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Kategorie *" FontSize="12" Opacity="0.7"/>
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
<TextBlock Text="{Binding DueDateError}" Foreground="Red" FontSize="11"
|
<TextBlock Text="{Binding DueDateError}" Foreground="Red" FontSize="11"
|
||||||
IsVisible="{Binding DueDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding DueDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4" IsVisible="{Binding !IsReminder}">
|
||||||
<TextBlock Text="Geschätzte Dauer in min (optional)" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Geschätzte Dauer in min (optional)" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding EstimatedMinutesText}" PlaceholderText="z.B. 90"/>
|
<TextBox Text="{Binding EstimatedMinutesText}" PlaceholderText="z.B. 90"/>
|
||||||
<TextBlock Text="{Binding EstimatedMinutesError}" Foreground="Red" FontSize="11"
|
<TextBlock Text="{Binding EstimatedMinutesError}" Foreground="Red" FontSize="11"
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
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}"/>
|
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="🔔 + Erinnerung" Command="{Binding AddReminderCommand}"
|
||||||
|
ToolTip.Tip="Kurze Notiz ohne Zeitbezug, z.B. 'morgen Ansage an die Klasse machen' — läuft nicht in die Zeiterfassung/Auswertung ein."/>
|
||||||
|
<Button Content="+ Neue Aufgabe" Command="{Binding AddTaskCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1" Margin="16,10,16,16">
|
<ScrollViewer Grid.Row="1" Margin="16,10,16,16">
|
||||||
@@ -30,7 +34,11 @@
|
|||||||
<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="1" Margin="10,0" VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/>
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<TextBlock Text="🔔" FontSize="12" IsVisible="{Binding IsReminder}"
|
||||||
|
ToolTip.Tip="Erinnerung — kein Zeitbezug, nicht Teil der Auswertung"/>
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
<TextBlock FontSize="11" Opacity="0.6">
|
<TextBlock FontSize="11" Opacity="0.6">
|
||||||
<Run Text="{Binding CategoryLabel}"/>
|
<Run Text="{Binding CategoryLabel}"/>
|
||||||
<Run Text=" · "/>
|
<Run Text=" · "/>
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ public partial class WorkTaskListView : UserControl
|
|||||||
vm.OnEditTask = ShowEditDialog;
|
vm.OnEditTask = ShowEditDialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<WorkTask?> ShowEditDialog(WorkTask? source)
|
private async Task<WorkTask?> ShowEditDialog(WorkTask? source, bool startAsReminder)
|
||||||
{
|
{
|
||||||
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;
|
||||||
|
|
||||||
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll(includeInactive: true);
|
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll(includeInactive: true);
|
||||||
var vm = new AddEditWorkTaskDialogViewModel(source, groups);
|
var vm = new AddEditWorkTaskDialogViewModel(source, groups, startAsReminder);
|
||||||
var dialog = new AddEditWorkTaskDialog { DataContext = vm };
|
var dialog = new AddEditWorkTaskDialog { DataContext = vm };
|
||||||
await dialog.ShowDialog<bool>(owner);
|
await dialog.ShowDialog<bool>(owner);
|
||||||
return vm.Result;
|
return vm.Result;
|
||||||
|
|||||||
@@ -1112,7 +1112,16 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
|
|||||||
(`OtherSchoolEvent`) zählt bewusst nicht als Fehlzeit des Schülers.
|
(`OtherSchoolEvent`) zählt bewusst nicht als Fehlzeit des Schülers.
|
||||||
- [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im
|
- [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im
|
||||||
Schülerdetail sowie eine neue "Fehlzeiten-Warnung"-Karte im Dashboard (alle Schüler über
|
Schülerdetail sowie eine neue "Fehlzeiten-Warnung"-Karte im Dashboard (alle Schüler über
|
||||||
dem Schwellenwert, sortiert nach Fehlquote).
|
dem Schwellenwert, sortiert nach Fehlquote). **Nachtrag (Nutzer-Feedback):** die
|
||||||
|
Dashboard-Karte meldete anfangs auch Schüler mit nur ein oder zwei erfassten Terminen — wer
|
||||||
|
zu Schuljahresbeginn einmal fehlt, steht damit rechnerisch schon bei 100 %, obwohl das noch
|
||||||
|
kein auffälliges Muster ist, nur eine zu kleine Stichprobe. `DashboardViewModel.LoadAttendanceWarnings`
|
||||||
|
meldet jetzt erst ab `AttendanceMinSampleSize` (8) im laufenden Schuljahr erfassten Terminen
|
||||||
|
(`AttendanceBalance.TotalChecked`) — dieselbe Konstante und Prüfung wie in der
|
||||||
|
Kurs-Übersicht (14.12, `GroupOverviewViewModel`), an der das zuerst aufgefallen ist.
|
||||||
|
`AttendanceBalanceService` selbst bleibt unverändert (liefert weiterhin die reine Quote ohne
|
||||||
|
Mindeststichprobe, z.B. für die Anzeige im Schülerdetail) — die Gate-Prüfung liegt bewusst
|
||||||
|
nur bei den beiden "Warnung"-Konsumenten.
|
||||||
- [x] **5.2.4** Nachträgliches Entschuldigen mit Frist-Hinweis — bereits vorhanden über die
|
- [x] **5.2.4** Nachträgliches Entschuldigen mit Frist-Hinweis — bereits vorhanden über die
|
||||||
"Offene Entschuldigungen"-Karte im Dashboard (21-Tage-Grenze, aus Kapitel 3).
|
"Offene Entschuldigungen"-Karte im Dashboard (21-Tage-Grenze, aus Kapitel 3).
|
||||||
|
|
||||||
@@ -1177,6 +1186,17 @@ CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus
|
|||||||
auf `Conducted` (Anbindung an 1.1.3) — Hook in `GroupDetailViewModel.SetExamStatus`, feuert
|
auf `Conducted` (Anbindung an 1.1.3) — Hook in `GroupDetailViewModel.SetExamStatus`, feuert
|
||||||
nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten
|
nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten
|
||||||
Klick), Fälligkeit 14 Tage nach dem Klausurtermin, Kategorie `Correction`.
|
Klick), Fälligkeit 14 Tage nach dem Klausurtermin, Kategorie `Correction`.
|
||||||
|
- [x] **6.1.6** "Erinnerung" als eigene Aufgabenart (Nutzer-Feedback): "Aufgaben" wirkte wie reine
|
||||||
|
Zeiterfassung, dabei braucht es auch schlichte Reminder ohne Zeitbezug (z.B. "morgen Ansage
|
||||||
|
an die Klasse machen"). Neues `WorkTask.Kind` (`TaskKind`: `WorkItem`/`Reminder`, Default
|
||||||
|
`WorkItem` — kein Migrationsschritt nötig, fehlendes BSON-Feld deserialisiert auf den
|
||||||
|
Enum-Default). Eigener Button "🔔 + Erinnerung" neben "+ Neue Aufgabe"
|
||||||
|
(`WorkTaskListViewModel.AddReminderCommand`) öffnet denselben `AddEditWorkTaskDialog`, aber
|
||||||
|
mit der Checkbox "Nur Erinnerung" vorbelegt — dadurch entfällt automatisch das
|
||||||
|
"Geschätzte Dauer"-Feld (Erinnerungen bekommen nie einen Zeitwert). Läuft bewusst nicht in
|
||||||
|
die Zeitauswertung (6.3) ein — die bereits rein datenmodell-bedingt, da `WorkloadEvaluationViewModel`
|
||||||
|
nur tatsächlich erfasste `TimeEntry`-Zeilen auswertet, keine `WorkTask`-Metadaten. In der
|
||||||
|
Aufgabenliste und im Dashboard-Widget "Offene Aufgaben" durch ein 🔔-Symbol gekennzeichnet.
|
||||||
|
|
||||||
### 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 —
|
||||||
@@ -1272,6 +1292,14 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
|
|||||||
Mitarbeitsaspekte, Anwesenheit und Hausaufgaben. Die Eingabe unterstützt Maus sowie
|
Mitarbeitsaspekte, Anwesenheit und Hausaufgaben. Die Eingabe unterstützt Maus sowie
|
||||||
Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert,
|
Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert,
|
||||||
wird einmalig eine Sitzung „Sitzplan“ für heute angelegt.
|
wird einmalig eine Sitzung „Sitzplan“ für heute angelegt.
|
||||||
|
**Bugfix (Nutzer-Feedback, "Geistereinträge"):** Diese Sitzung entstand ursprünglich schon
|
||||||
|
beim bloßen Initialisieren des Sitzplan-Tabs (`SeatingPlanTabViewModel.LoadTodaySessions`)
|
||||||
|
— und `GroupDetailViewModel.LoadGroup` initialisiert alle Tabs einer Gruppe sofort beim
|
||||||
|
Navigieren dorthin, unabhängig davon, welcher Tab tatsächlich angesehen wird. Ergebnis:
|
||||||
|
praktisch jede an dem Tag geöffnete Gruppe bekam eine leere "Sitzplan"-Sitzung, auch wenn nie
|
||||||
|
tatsächlich bewertet wurde. `LoadTodaySessions()` legt jetzt keine Sitzung mehr an; die
|
||||||
|
Erzeugung passiert erst über `EnsureTodaySession()` bei der ersten tatsächlichen Aktion
|
||||||
|
(Sitzplatz-Bewertung oder Situations-Tag).
|
||||||
|
|
||||||
### 7.2 Gruppen
|
### 7.2 Gruppen
|
||||||
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
|
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
|
||||||
@@ -1366,6 +1394,18 @@ Basis vorhanden in [DashboardViewModel.cs](LehrerApp.Desktop/ViewModels/Dashboar
|
|||||||
Kleiner Monatskalender ist bereits umgesetzt (Farbcodierung Unterricht/Klausur,
|
Kleiner Monatskalender ist bereits umgesetzt (Farbcodierung Unterricht/Klausur,
|
||||||
Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreite/-position).
|
Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreite/-position).
|
||||||
|
|
||||||
|
**Zwei Bugfixes am Kalender (Nutzer-Feedback):**
|
||||||
|
- Eine über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstandene `ParticipationSession`
|
||||||
|
(`LessonId` gesetzt) erzeugte einen zweiten, redundanten Kalendereintrag neben der Stunde selbst
|
||||||
|
— dieselbe Unterrichtsstunde stand doppelt in der Tagesliste. `LoadCalendar()` überspringt
|
||||||
|
jetzt Sitzungen mit gesetzter `LessonId` (die zugehörige Lesson deckt den Tag bereits ab).
|
||||||
|
- Der "Meine Klasse"-Ring war nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
|
||||||
|
existierte — ein Tag, an dem laut Stundenplan (4.3) die eigene Klasse ansteht, aber noch keine
|
||||||
|
Lesson angelegt wurde (typischerweise "morgen"), zeigte den Ring fälschlich nicht. `LoadCalendar()`
|
||||||
|
projiziert `IsOwnClassDay` jetzt zusätzlich aus dem Stundenplan (`TimetableSlot`, abzüglich
|
||||||
|
Ferien/Feiertage und `SubstitutionKind.Cancelled`) — dieselbe Projektion wie bei den "ungeplanten
|
||||||
|
Stunden" (`LoadUnplannedLessons`), nur über den ganzen Kalenderraster statt nur die nächsten Tage.
|
||||||
|
|
||||||
- [x] **9.1** Heutige Stunden mit Uhrzeit und Raum anzeigen (Abhängigkeit zu 4.3, jetzt erfüllt).
|
- [x] **9.1** Heutige Stunden mit Uhrzeit und Raum anzeigen (Abhängigkeit zu 4.3, jetzt erfüllt).
|
||||||
**Umsetzung:** `DashboardViewModel.TodaysLessons` löst je Stunde den Raum über den zur
|
**Umsetzung:** `DashboardViewModel.TodaysLessons` löst je Stunde den Raum über den zur
|
||||||
Stundennummer und dem heutigen Wochentag passenden `TimetableSlot` auf, die Uhrzeit aus
|
Stundennummer und dem heutigen Wochentag passenden `TimetableSlot` auf, die Uhrzeit aus
|
||||||
@@ -2185,7 +2225,16 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
|||||||
- [ ] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
|
- [ ] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
|
||||||
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
|
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
|
||||||
- [ ] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger
|
- [ ] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger
|
||||||
Emoji-Darstellung einführen.
|
Emoji-Darstellung einführen. **Konkreter Bericht (Nutzer-Feedback):** Drawer-Icons erscheinen
|
||||||
|
auf einem Windows-PC gegen 22 Uhr einfarbig schwarz statt farbig — passend zum bekannten
|
||||||
|
Windows-Verhalten, dass Emoji-Codepunkte je nach Font-Fallback statt der farbigen
|
||||||
|
"Segoe UI Emoji" auf die einfarbige "Segoe UI Symbol" ausweichen können (typischer Auslöser:
|
||||||
|
ein zeitgesteuerter Hell/Dunkel-Wechsel des Systemthemas). Als Sofortmaßnahme pinnt
|
||||||
|
`TextBlock.navicon` in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml) jetzt
|
||||||
|
explizit `FontFamily="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"` — auf anderen Plattformen
|
||||||
|
folgenlos, da unbekannte Fontnamen einfach übersprungen werden. Nur eine Abmilderung für die
|
||||||
|
Drawer-Navigation, kein Nachweis der Ursache und keine Lösung für die übrigen Emoji im Rest
|
||||||
|
der App — die eigentliche, dauerhafte Lösung bleibt dieser Punkt (echtes Icon-Set).
|
||||||
- [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird
|
- [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird
|
||||||
über `MainWindowViewModel.ActiveNavItem` gesteuert.
|
über `MainWindowViewModel.ActiveNavItem` gesteuert.
|
||||||
- [x] **14.12** Tab "Übersicht" im Kurs (`GroupDetailView`, bislang nur Platzhaltertext) gefüllt —
|
- [x] **14.12** Tab "Übersicht" im Kurs (`GroupDetailView`, bislang nur Platzhaltertext) gefüllt —
|
||||||
@@ -2200,13 +2249,18 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
|
|||||||
Karte wie im globalen Dashboard (9), hier nur auf den einen Kurs eingeschränkt statt über
|
Karte wie im globalen Dashboard (9), hier nur auf den einen Kurs eingeschränkt statt über
|
||||||
alle Gruppen aggregiert. **Nachtrag (Nutzer-Feedback):** zwei weitere Karten — offene
|
alle Gruppen aggregiert. **Nachtrag (Nutzer-Feedback):** zwei weitere Karten — offene
|
||||||
Dokumentationen (`Documentation.IsDraft`-Zählung, dieselbe wie das Badge in
|
Dokumentationen (`Documentation.IsDraft`-Zählung, dieselbe wie das Badge in
|
||||||
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Letztere bewusst KEIN 1:1-Abbild
|
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Dieselbe Quote wie die
|
||||||
der Dashboard-Fehlzeiten-Warnung: dieselbe Quote (`AttendanceBalanceService.WarningThresholdPercent`,
|
Dashboard-Fehlzeiten-Warnung (`AttendanceBalanceService.WarningThresholdPercent`, 20 %),
|
||||||
20 %), aber erst ab `GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten
|
aber optisch zurückhaltender (kein rotes Warnfall-Badge) und erst ab
|
||||||
Anwesenheits-Terminen im laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal
|
`GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten Anwesenheits-Terminen im
|
||||||
fehlt, hat rechnerisch schon 100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine
|
laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rechnerisch schon
|
||||||
zu kleine Stichprobe. Dasselbe Problem hat die Fehlzeiten-Warnung im Hauptdashboard (5.2.3)
|
100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine zu kleine Stichprobe.
|
||||||
aktuell noch — dort bewusst unangetastet gelassen, wird separat nachgezogen.
|
Dieselbe Mindeststichprobe wurde im Zuge dessen auch in die Dashboard-Fehlzeiten-Warnung
|
||||||
|
selbst nachgezogen, siehe 5.2.3. **Zweiter Nachtrag (Nutzer-Feedback):** dritte Karte
|
||||||
|
"Fehlende Hausaufgaben" — Schüler mit `ParticipationEntry.HomeworkMissing` aus der letzten
|
||||||
|
Mitarbeitssitzung (bewusst nur die letzte, nicht die gesamte Historie, analog zur
|
||||||
|
Hausaufgaben-Kontrolle oben), mit dem konkreten Status je Schüler (`HomeworkDisplay.Label`,
|
||||||
|
z.B. "Nicht gemacht – Nachreichen offen").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user