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; }
|
||||
// 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;
|
||||
/// 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 UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -112,3 +118,4 @@ public class TimeEntry
|
||||
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
|
||||
public enum WorkTaskStatus { Open, InProgress, Done }
|
||||
public enum TaskRecurrence { None, Weekly, Monthly }
|
||||
public enum TaskKind { WorkItem, Reminder }
|
||||
|
||||
@@ -6,8 +6,9 @@ using Xunit;
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// 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
|
||||
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...).
|
||||
/// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
|
||||
/// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
|
||||
/// (offene Aufgaben, ...).
|
||||
public sealed class DashboardViewModelTests
|
||||
{
|
||||
private static PeriodScheduleService NewPeriodSchedule()
|
||||
@@ -41,7 +42,7 @@ public sealed class DashboardViewModelTests
|
||||
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeSessions? sessions = null)
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null)
|
||||
{
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
@@ -49,7 +50,7 @@ public sealed class DashboardViewModelTests
|
||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||
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(),
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
@@ -243,6 +244,45 @@ public sealed class DashboardViewModelTests
|
||||
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]
|
||||
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
|
||||
{
|
||||
@@ -373,4 +413,53 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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.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 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);
|
||||
|
||||
@@ -179,6 +179,26 @@ public sealed class WorkTaskListViewModelTests
|
||||
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]
|
||||
public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde()
|
||||
{
|
||||
@@ -285,6 +305,38 @@ public sealed class AddEditWorkTaskDialogViewModelTests
|
||||
Assert.NotNull(vm.Result);
|
||||
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
|
||||
|
||||
@@ -42,6 +42,11 @@ public partial class DashboardViewModel : ObservableObject
|
||||
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
|
||||
/// um sich abends noch vorzubereiten.
|
||||
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 _currentDate = "";
|
||||
@@ -156,7 +161,8 @@ public partial class DashboardViewModel : ObservableObject
|
||||
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5))
|
||||
OpenTasks.Add(new() { Title = t.Title,
|
||||
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();
|
||||
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));
|
||||
|
||||
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));
|
||||
}
|
||||
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
||||
@@ -555,8 +561,12 @@ public partial class DashboardViewModel : ObservableObject
|
||||
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)
|
||||
.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);
|
||||
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++)
|
||||
{
|
||||
var date = gridStart.AddDays(i);
|
||||
@@ -659,7 +698,7 @@ public class LessonItem
|
||||
public string Room { get; set; } = "";
|
||||
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; } = ""; }
|
||||
|
||||
// ── 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
|
||||
/// 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
|
||||
/// Stichprobe. Dasselbe Problem hat aktuell noch die Fehlzeiten-Warnung im Hauptdashboard
|
||||
/// (AttendanceBalanceService/AttendanceWarningItem) — bewusst unangetastet, wird dort separat
|
||||
/// nachgezogen.
|
||||
/// Stichprobe. Dieselbe Konstante wie DashboardViewModel.AttendanceMinSampleSize
|
||||
/// (Hauptdashboard-Fehlzeiten-Warnung, 5.2.3) — dort dasselbe Problem, mittlerweile ebenfalls
|
||||
/// gefixt.
|
||||
private const int AttendanceMinSampleSize = 8;
|
||||
|
||||
private readonly ILessonRepository _lessons;
|
||||
@@ -55,6 +55,10 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
|
||||
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") —
|
||||
/// siehe <see cref="AttendanceMinSampleSize"/>.
|
||||
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
|
||||
@@ -97,6 +101,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
LoadOpenExcuses(today);
|
||||
LoadDraftDocumentationCount();
|
||||
LoadAttendanceNotices(today);
|
||||
LoadMissingHomework();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// Bewusst kein 1:1-Abbild der Dashboard-Fehlzeiten-Warnung: dieselbe Quote
|
||||
/// (AttendanceBalanceService.WarningThresholdPercent), aber erst ab
|
||||
/// <see cref="AttendanceMinSampleSize"/> erfassten Terminen, damit ein Fehltag in der ersten
|
||||
/// Schulwoche nicht sofort als auffällig gilt.
|
||||
/// Dieselbe Mindeststichprobe wie DashboardViewModel.LoadAttendanceWarnings
|
||||
/// (AttendanceMinSampleSize) — erst ab genug erfassten Terminen wird eine hohe Quote gemeldet,
|
||||
/// damit ein Fehltag in der ersten Schulwoche nicht sofort als auffällig gilt.
|
||||
private void LoadAttendanceNotices(DateOnly today)
|
||||
{
|
||||
AttendanceNotices.Clear();
|
||||
@@ -225,15 +229,41 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
var items = new List<AttendanceWarningItem>();
|
||||
foreach (var student in _students.GetByGroup(_groupId))
|
||||
{
|
||||
if (!entriesByStudent.TryGetValue(student.Id, out var entries) || entries.Count < AttendanceMinSampleSize)
|
||||
continue;
|
||||
if (!entriesByStudent.TryGetValue(student.Id, out var entries)) continue;
|
||||
|
||||
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));
|
||||
}
|
||||
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
|
||||
AttendanceNotices.Add(item);
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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()
|
||||
{
|
||||
TodaySessions.Clear();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today)
|
||||
.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));
|
||||
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();
|
||||
|
||||
private void LoadStudentOptions()
|
||||
@@ -193,19 +210,21 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
|
||||
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;
|
||||
var session = EnsureTodaySession();
|
||||
if (session is null) return;
|
||||
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)
|
||||
{
|
||||
draft = new Documentation
|
||||
{
|
||||
StudentId = studentId, GroupId = _groupId,
|
||||
ParticipationSessionId = SelectedSession.Id,
|
||||
LessonId = SelectedSession.LessonId,
|
||||
ParticipationSessionId = session.Id,
|
||||
LessonId = session.LessonId,
|
||||
Type = DocumentationType.Incident,
|
||||
Date = SelectedSession.Date,
|
||||
Date = session.Date,
|
||||
Title = tag,
|
||||
IsDraft = true,
|
||||
Tags = [tag],
|
||||
@@ -297,9 +316,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject
|
||||
public async Task AssessStudent(SeatCellViewModel seat)
|
||||
{
|
||||
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
|
||||
var session = EnsureTodaySession();
|
||||
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
|
||||
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
|
||||
SelectedSession?.Id);
|
||||
session?.Id);
|
||||
await OnAssessStudent(assessment);
|
||||
RefreshSeatLessonData();
|
||||
OnAssessmentChanged?.Invoke();
|
||||
|
||||
@@ -97,7 +97,8 @@ public partial class WorkTaskListViewModel : ObservableObject
|
||||
public ObservableCollection<WorkTaskListItem> Tasks { get; } = [];
|
||||
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 = [];
|
||||
|
||||
@@ -166,7 +167,20 @@ public partial class WorkTaskListViewModel : ObservableObject
|
||||
private async Task AddTask()
|
||||
{
|
||||
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;
|
||||
_tasks.Save(result);
|
||||
Refresh();
|
||||
@@ -176,7 +190,7 @@ public partial class WorkTaskListViewModel : ObservableObject
|
||||
private async Task EditTask(WorkTaskListItem? item)
|
||||
{
|
||||
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;
|
||||
_tasks.Save(result);
|
||||
Refresh();
|
||||
@@ -203,6 +217,7 @@ public partial class WorkTaskListViewModel : ObservableObject
|
||||
? dueDate.AddDays(7) : dueDate.AddMonths(1),
|
||||
EstimatedMinutes = item.Model.EstimatedMinutes,
|
||||
Recurrence = item.Model.Recurrence,
|
||||
Kind = item.Model.Kind,
|
||||
Notes = item.Model.Notes,
|
||||
Status = WorkTaskStatus.Open,
|
||||
});
|
||||
@@ -224,7 +239,8 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut
|
||||
{
|
||||
public WorkTask Model { get; } = model;
|
||||
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 DueDateDisplay => Model.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
|
||||
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 _selectedRecurrence = TaskRecurrenceDisplay.Options[0];
|
||||
[ObservableProperty] private string _notes = "";
|
||||
[ObservableProperty] private bool _isReminder;
|
||||
[ObservableProperty] private string _titleError = "";
|
||||
[ObservableProperty] private string _dueDateError = "";
|
||||
[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> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options];
|
||||
public List<LearningGroup> Groups { get; }
|
||||
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;
|
||||
Groups = groups;
|
||||
if (source is null) return;
|
||||
if (source is null) { IsReminder = startAsReminder; return; }
|
||||
|
||||
Title = source.Title;
|
||||
SelectedCategory = TaskCategoryDisplay.Label(source.Category);
|
||||
@@ -281,6 +302,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
|
||||
EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||
SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence);
|
||||
Notes = source.Notes ?? "";
|
||||
IsReminder = source.Kind == TaskKind.Reminder;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -303,8 +325,9 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
|
||||
if (recurrence != TaskRecurrence.None && dueDate is null)
|
||||
{ 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;
|
||||
if (!string.IsNullOrWhiteSpace(EstimatedMinutesText))
|
||||
if (!IsReminder && !string.IsNullOrWhiteSpace(EstimatedMinutesText))
|
||||
{
|
||||
if (!int.TryParse(EstimatedMinutesText, out var m) || m <= 0)
|
||||
{ EstimatedMinutesError = "Ganze Zahl > 0 erwartet."; valid = false; }
|
||||
@@ -322,6 +345,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
|
||||
DueDate = dueDate,
|
||||
EstimatedMinutes = estimatedMinutes,
|
||||
Recurrence = recurrence,
|
||||
Kind = IsReminder ? TaskKind.Reminder : TaskKind.WorkItem,
|
||||
Status = _source?.Status ?? WorkTaskStatus.Open,
|
||||
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
|
||||
CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow,
|
||||
|
||||
@@ -108,10 +108,13 @@
|
||||
<ItemsControl ItemsSource="{Binding OpenTasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:TaskItem">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,3">
|
||||
<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"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding DueDate}"
|
||||
<TextBlock Grid.Column="2" Text="{Binding DueDate}"
|
||||
FontSize="12" Opacity="0.6" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -143,6 +143,28 @@
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</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>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -67,10 +67,14 @@
|
||||
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
|
||||
<DockPanel.Styles>
|
||||
<!-- 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">
|
||||
<Setter Property="FontSize" Value="15"/>
|
||||
<Setter Property="Width" Value="24"/>
|
||||
<Setter Property="FontFamily" Value="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"/>
|
||||
</Style>
|
||||
<Style Selector="Button.navitem">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<CheckBox Content="🔔 Nur Erinnerung (kein Zeitbezug, läuft nicht in die Zeiterfassung ein)"
|
||||
IsChecked="{Binding IsReminder}"/>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Kategorie *" FontSize="12" Opacity="0.7"/>
|
||||
@@ -46,7 +49,7 @@
|
||||
<TextBlock Text="{Binding DueDateError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding DueDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</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"/>
|
||||
<TextBox Text="{Binding EstimatedMinutesText}" PlaceholderText="z.B. 90"/>
|
||||
<TextBlock Text="{Binding EstimatedMinutesError}" Foreground="Red" FontSize="11"
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
SelectedItem="{Binding CategoryFilter}" Margin="0,0,8,0"/>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding GroupFilterOptions}"
|
||||
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>
|
||||
|
||||
<ScrollViewer Grid.Row="1" Margin="16,10,16,16">
|
||||
@@ -30,7 +34,11 @@
|
||||
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Foreground="White"/>
|
||||
</Button>
|
||||
<StackPanel Grid.Column="1" Margin="10,0" VerticalAlignment="Center">
|
||||
<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">
|
||||
<Run Text="{Binding CategoryLabel}"/>
|
||||
<Run Text=" · "/>
|
||||
|
||||
@@ -17,13 +17,13 @@ public partial class WorkTaskListView : UserControl
|
||||
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;
|
||||
if (owner is null) return null;
|
||||
|
||||
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 };
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
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.
|
||||
- [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im
|
||||
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
|
||||
"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
|
||||
nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten
|
||||
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
|
||||
- [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
|
||||
Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert,
|
||||
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
|
||||
- [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,
|
||||
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).
|
||||
**Umsetzung:** `DashboardViewModel.TodaysLessons` löst je Stunde den Raum über den zur
|
||||
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,
|
||||
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
|
||||
- [ ] **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
|
||||
über `MainWindowViewModel.ActiveNavItem` gesteuert.
|
||||
- [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
|
||||
alle Gruppen aggregiert. **Nachtrag (Nutzer-Feedback):** zwei weitere Karten — offene
|
||||
Dokumentationen (`Documentation.IsDraft`-Zählung, dieselbe wie das Badge in
|
||||
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Letztere bewusst KEIN 1:1-Abbild
|
||||
der Dashboard-Fehlzeiten-Warnung: dieselbe Quote (`AttendanceBalanceService.WarningThresholdPercent`,
|
||||
20 %), aber erst ab `GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten
|
||||
Anwesenheits-Terminen im laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal
|
||||
fehlt, hat rechnerisch schon 100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine
|
||||
zu kleine Stichprobe. Dasselbe Problem hat die Fehlzeiten-Warnung im Hauptdashboard (5.2.3)
|
||||
aktuell noch — dort bewusst unangetastet gelassen, wird separat nachgezogen.
|
||||
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Dieselbe Quote wie die
|
||||
Dashboard-Fehlzeiten-Warnung (`AttendanceBalanceService.WarningThresholdPercent`, 20 %),
|
||||
aber optisch zurückhaltender (kein rotes Warnfall-Badge) und erst ab
|
||||
`GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten Anwesenheits-Terminen im
|
||||
laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rechnerisch schon
|
||||
100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine zu kleine Stichprobe.
|
||||
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