feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard
- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
und der letzten Stunde der Einheit vorbelegt statt auf "heute"
(behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
für die zweite Periode) und Berücksichtigung von Stundenausfall.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,13 @@ public class LearningGroup
|
|||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public bool IsOwnClass { get; set; }
|
public bool IsOwnClass { get; set; }
|
||||||
public bool IsDifferentiated { get; set; }
|
public bool IsDifferentiated { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
|
||||||
|
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
|
||||||
|
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
|
||||||
|
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequiresLessonPlanning { get; set; } = true;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,6 @@ public class TimeEntry
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
|
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 }
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed class DashboardSettingsService
|
|||||||
public static readonly string[] DefaultCardOrder =
|
public static readonly string[] DefaultCardOrder =
|
||||||
[
|
[
|
||||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||||
"corrections", "alerts", "attendance", "support", "groups",
|
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly string _configPath;
|
private readonly string _configPath;
|
||||||
|
|||||||
@@ -26,14 +26,23 @@ public sealed class DashboardViewModelTests
|
|||||||
return new DashboardSettingsService(tempPath);
|
return new DashboardSettingsService(tempPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SchoolCalendarSettingsService NewCalendarSettings()
|
||||||
|
{
|
||||||
|
var tempPath = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new SchoolCalendarSettingsService(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
|
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
|
||||||
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
|
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
|
||||||
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||||
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
|
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
|
||||||
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
||||||
DashboardSettingsService? dashboardSettings = null)
|
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||||
|
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null)
|
||||||
{
|
{
|
||||||
var lessons = new FakeLessons();
|
lessons ??= new FakeLessons();
|
||||||
lessons.Add(lesson);
|
lessons.Add(lesson);
|
||||||
return new DashboardViewModel(
|
return new DashboardViewModel(
|
||||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||||
@@ -42,7 +51,9 @@ public sealed class DashboardViewModelTests
|
|||||||
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
|
tasks ?? new FakeWorkTasks(), new FakeSessions([]), 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(),
|
||||||
|
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||||
|
substitutions ?? new FakeSubstitutionEntries());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -227,4 +238,119 @@ public sealed class DashboardViewModelTests
|
|||||||
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
|
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
|
||||||
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
|
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
// "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
|
||||||
|
// Vorgriff fällt und den Test verfälscht.
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||||
|
|
||||||
|
var item = Assert.Single(vm.UnplannedLessons);
|
||||||
|
Assert.Equal(group.Id, item.GroupId);
|
||||||
|
Assert.Equal(1, item.PeriodNumber);
|
||||||
|
Assert.Equal("Heute", item.DateDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redoxreaktionen" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_Feiertag_WirdUebersprungen()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||||
|
var schoolHolidays = new FakeSchoolHolidays();
|
||||||
|
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||||
|
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
|
||||||
|
|
||||||
|
var vm = BuildVm(group, lesson, slots: slots);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.UnplannedLessons.Count);
|
||||||
|
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
|
||||||
|
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var group = new LearningGroup { Name = "9c" };
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
|
||||||
|
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
|
||||||
|
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
|
||||||
|
var substitutions = new FakeSubstitutionEntries();
|
||||||
|
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
|
||||||
|
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
|
||||||
|
|
||||||
|
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
|
||||||
|
|
||||||
|
Assert.Empty(vm.UnplannedLessons);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
|
|
||||||
@@ -47,6 +48,14 @@ public static class TestSupport
|
|||||||
Directory.CreateDirectory(tempPath);
|
Directory.CreateDirectory(tempPath);
|
||||||
return new EventQueue(Path.Combine(tempPath, "queue.db"));
|
return new EventQueue(Path.Combine(tempPath, "queue.db"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||||
|
public static PeriodScheduleService BuildPeriodScheduleService()
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempPath);
|
||||||
|
return new PeriodScheduleService(tempPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||||
|
|||||||
@@ -388,4 +388,53 @@ public sealed class LessonDialogViewModelTests
|
|||||||
|
|
||||||
Assert.False(vm.HasTimeBudgetInfo);
|
Assert.False(vm.HasTimeBudgetInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf
|
||||||
|
// "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode
|
||||||
|
// zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer
|
||||||
|
|
||||||
|
Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 });
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
|
||||||
|
|
||||||
|
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||||
|
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||||
|
|
||||||
|
// Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
|
||||||
|
Assert.Equal("13.01.2099", vm.DateText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 });
|
||||||
|
var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
|
||||||
|
|
||||||
|
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||||
|
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||||
|
|
||||||
|
var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
|
||||||
|
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
Assert.Equal(weekday, suggested.DayOfWeek);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Workload;
|
using LehrerApp.Desktop.ViewModels.Workload;
|
||||||
@@ -288,14 +289,16 @@ public sealed class AddEditWorkTaskDialogViewModelTests
|
|||||||
|
|
||||||
public sealed class TimeTrackingViewModelTests
|
public sealed class TimeTrackingViewModelTests
|
||||||
{
|
{
|
||||||
|
private static TimeTrackingViewModel BuildVm(ITimeEntryRepository entries, IWorkTaskRepository tasks,
|
||||||
|
FakeTimetableSlots? slots = null) =>
|
||||||
|
new(entries, tasks, slots ?? new FakeTimetableSlots(), TestSupport.BuildPeriodScheduleService());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
|
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
|
||||||
{
|
{
|
||||||
var entries = new FakeTimeEntries();
|
var entries = new FakeTimeEntries();
|
||||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
|
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||||
{
|
vm.SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction);
|
||||||
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
|
|
||||||
};
|
|
||||||
|
|
||||||
vm.StartTimerCommand.Execute(null);
|
vm.StartTimerCommand.Execute(null);
|
||||||
Assert.True(vm.IsTimerRunning);
|
Assert.True(vm.IsTimerRunning);
|
||||||
@@ -314,7 +317,7 @@ public sealed class TimeTrackingViewModelTests
|
|||||||
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
|
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
|
||||||
var tasks = new FakeWorkTasks();
|
var tasks = new FakeWorkTasks();
|
||||||
tasks.Add(task);
|
tasks.Add(task);
|
||||||
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
|
var vm = BuildVm(new FakeTimeEntries(), tasks);
|
||||||
|
|
||||||
vm.SelectedTimerTask = task;
|
vm.SelectedTimerTask = task;
|
||||||
|
|
||||||
@@ -328,7 +331,7 @@ public sealed class TimeTrackingViewModelTests
|
|||||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
|
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
|
||||||
|
|
||||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||||
|
|
||||||
Assert.Single(vm.WeekEntries);
|
Assert.Single(vm.WeekEntries);
|
||||||
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
|
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
|
||||||
@@ -343,7 +346,7 @@ public sealed class TimeTrackingViewModelTests
|
|||||||
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
|
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
|
||||||
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
|
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
|
||||||
|
|
||||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||||
|
|
||||||
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
|
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
|
||||||
Assert.Equal("50 min", korrektur.MinutesDisplay);
|
Assert.Equal("50 min", korrektur.MinutesDisplay);
|
||||||
@@ -356,7 +359,7 @@ public sealed class TimeTrackingViewModelTests
|
|||||||
{
|
{
|
||||||
var entries = new FakeTimeEntries();
|
var entries = new FakeTimeEntries();
|
||||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||||
|
|
||||||
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
|
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
|
||||||
|
|
||||||
@@ -367,7 +370,7 @@ public sealed class TimeTrackingViewModelTests
|
|||||||
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
|
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
|
||||||
{
|
{
|
||||||
var entries = new FakeTimeEntries();
|
var entries = new FakeTimeEntries();
|
||||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||||
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
|
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
|
||||||
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
|
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
|
||||||
|
|
||||||
@@ -375,6 +378,64 @@ public sealed class TimeTrackingViewModelTests
|
|||||||
|
|
||||||
Assert.Single(vm.WeekEntries);
|
Assert.Single(vm.WeekEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Unterrichtszeit-Vorschlag (Nutzer-Feedback: Zeiterfassung soll bei der Unterrichtszeit
|
||||||
|
// nachfragen statt sie komplett automatisch zu erfassen) ────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasTeachingTimeSuggestionToday_OhneStundenplanHeute_IstFalse()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks()); // leere FakeTimetableSlots
|
||||||
|
|
||||||
|
Assert.False(vm.HasTeachingTimeSuggestionToday);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestTeachingTime_SchlaegtBeginnMitPufferVorErsterUndNachLetzterPeriodeVor()
|
||||||
|
{
|
||||||
|
var today = DateTime.Today.DayOfWeek;
|
||||||
|
var slots = new FakeTimetableSlots();
|
||||||
|
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 1 });
|
||||||
|
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 2 });
|
||||||
|
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||||
|
periodSchedule.SetPeriods([
|
||||||
|
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
|
||||||
|
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
|
||||||
|
]);
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
|
||||||
|
|
||||||
|
Assert.True(vm.HasTeachingTimeSuggestionToday);
|
||||||
|
|
||||||
|
(TimeOnly Start, TimeOnly End)? suggested = null;
|
||||||
|
vm.OnSuggestTeachingTime = (start, end) =>
|
||||||
|
{
|
||||||
|
suggested = (start, end);
|
||||||
|
return Task.FromResult<TimeEntry?>(new TimeEntry
|
||||||
|
{
|
||||||
|
Category = "Unterricht", Date = DateOnly.FromDateTime(DateTime.Today),
|
||||||
|
StartTime = start, EndTime = end, DurationMinutes = (int)(end - start).TotalMinutes,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(new TimeOnly(7, 35), suggested!.Value.Start); // 7:50 - 15 Min Puffer
|
||||||
|
Assert.Equal(new TimeOnly(9, 30), suggested.Value.End); // 9:20 + 10 Min Puffer
|
||||||
|
Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestTeachingTime_OhneStundenplanHeute_TutNichts()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks());
|
||||||
|
var called = false;
|
||||||
|
vm.OnSuggestTeachingTime = (_, _) => { called = true; return Task.FromResult<TimeEntry?>(null); };
|
||||||
|
|
||||||
|
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(called);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AddTimeEntryDialogViewModelTests
|
public sealed class AddTimeEntryDialogViewModelTests
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ public class App : Application
|
|||||||
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
|
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
|
||||||
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
||||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||||
|
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||||
|
|
||||||
// StudentList → StudentDetail + Anlegen
|
// StudentList → StudentDetail + Anlegen
|
||||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||||
|
|||||||
@@ -30,10 +30,18 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
private readonly AttendanceBalanceService _attendanceBalance;
|
private readonly AttendanceBalanceService _attendanceBalance;
|
||||||
private readonly SchoolYearService _sy;
|
private readonly SchoolYearService _sy;
|
||||||
private readonly DashboardSettingsService _dashboardSettings;
|
private readonly DashboardSettingsService _dashboardSettings;
|
||||||
|
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||||
|
private readonly PublicHolidayService _publicHolidays;
|
||||||
|
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||||
|
private readonly ISubstitutionEntryRepository _substitutions;
|
||||||
|
|
||||||
private const int OpenExcuseMaxAgeDays = 21;
|
private const int OpenExcuseMaxAgeDays = 21;
|
||||||
private const int SupportPlanDueWithinDays = 14;
|
private const int SupportPlanDueWithinDays = 14;
|
||||||
private const int UpcomingWithinDays = 30;
|
private const int UpcomingWithinDays = 30;
|
||||||
|
/// Nutzer-Feedback: "der zeitliche Vorgriff sollte sinnvoll sein" - heute + morgen ist knapp
|
||||||
|
/// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
|
||||||
|
/// um sich abends noch vorzubereiten.
|
||||||
|
private const int UnplannedLessonsLookaheadDays = 1;
|
||||||
|
|
||||||
[ObservableProperty] private string _greeting = "";
|
[ObservableProperty] private string _greeting = "";
|
||||||
[ObservableProperty] private string _currentDate = "";
|
[ObservableProperty] private string _currentDate = "";
|
||||||
@@ -53,6 +61,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
|
||||||
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
|
||||||
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
|
||||||
|
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
|
||||||
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
|
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
|
||||||
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
|
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
|
||||||
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
|
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
|
||||||
@@ -66,6 +75,9 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
|
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
|
||||||
public Action<Guid>? OnNavigateToLesson { get; set; }
|
public Action<Guid>? OnNavigateToLesson { get; set; }
|
||||||
public Action<Guid>? OnNavigateToExam { get; set; }
|
public Action<Guid>? OnNavigateToExam { get; set; }
|
||||||
|
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
|
||||||
|
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
|
||||||
|
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
|
||||||
|
|
||||||
public DashboardCardOption TodayCard => Card("today");
|
public DashboardCardOption TodayCard => Card("today");
|
||||||
public DashboardCardOption TasksCard => Card("tasks");
|
public DashboardCardOption TasksCard => Card("tasks");
|
||||||
@@ -73,6 +85,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
public DashboardCardOption ExcusesCard => Card("excuses");
|
public DashboardCardOption ExcusesCard => Card("excuses");
|
||||||
public DashboardCardOption UpcomingCard => Card("upcoming");
|
public DashboardCardOption UpcomingCard => Card("upcoming");
|
||||||
public DashboardCardOption CorrectionsCard => Card("corrections");
|
public DashboardCardOption CorrectionsCard => Card("corrections");
|
||||||
|
public DashboardCardOption UnplannedCard => Card("unplanned");
|
||||||
public DashboardCardOption AlertsCard => Card("alerts");
|
public DashboardCardOption AlertsCard => Card("alerts");
|
||||||
public DashboardCardOption AttendanceCard => Card("attendance");
|
public DashboardCardOption AttendanceCard => Card("attendance");
|
||||||
public DashboardCardOption SupportCard => Card("support");
|
public DashboardCardOption SupportCard => Card("support");
|
||||||
@@ -85,7 +98,9 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
IParticipationRepository participationEntries, IStudentRepository students,
|
IParticipationRepository participationEntries, IStudentRepository students,
|
||||||
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
|
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
|
||||||
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
|
||||||
DashboardSettingsService dashboardSettings)
|
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
|
||||||
|
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
|
||||||
|
ISubstitutionEntryRepository substitutions)
|
||||||
{
|
{
|
||||||
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
|
||||||
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
|
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
|
||||||
@@ -93,6 +108,8 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
_students = students; _documentation = documentation;
|
_students = students; _documentation = documentation;
|
||||||
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
|
||||||
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
|
||||||
|
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
|
||||||
|
_substitutions = substitutions;
|
||||||
LoadDashboardCards();
|
LoadDashboardCards();
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
@@ -157,6 +174,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
LoadSupportPlanReviews(today);
|
LoadSupportPlanReviews(today);
|
||||||
LoadUpcomingDates(groups, today);
|
LoadUpcomingDates(groups, today);
|
||||||
LoadOpenCorrections(groups, today);
|
LoadOpenCorrections(groups, today);
|
||||||
|
LoadUnplannedLessons(groups, today);
|
||||||
LoadAlerts(groups, today);
|
LoadAlerts(groups, today);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +282,87 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ungeplante Stunden (Nutzer-Feedback: Erinnerung an Stunden ohne Thema) ────────────────
|
||||||
|
//
|
||||||
|
// Nur heute + UnplannedLessonsLookaheadDays (bewusst knapp, siehe Konstante oben) — für jede
|
||||||
|
// Gruppe mit RequiresLessonPlanning und einem Stundenplan-Slot an diesem Wochentag wird
|
||||||
|
// geprüft, ob dafür bereits eine Lesson mit Thema existiert. Gruppen ohne eigenen Verlaufsplan
|
||||||
|
// (Klassenrat, Willkommenskreis) lassen sich in den Stammdaten der Gruppe ausnehmen. Ein Slot,
|
||||||
|
// für den an diesem Datum ein SubstitutionKind.Cancelled-Eintrag (4.3, "Stundenausfall")
|
||||||
|
// vorliegt, fällt ganz weg — gleiche Prüfung (Datum + Stundennummer, ohne Gruppenbezug) wie in
|
||||||
|
// TimetableViewModel.BuildToday.
|
||||||
|
|
||||||
|
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||||
|
{
|
||||||
|
UnplannedLessons.Clear();
|
||||||
|
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
|
||||||
|
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
|
||||||
|
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
|
||||||
|
.Select(h => h.Date).ToHashSet();
|
||||||
|
var schoolHolidays = _schoolHolidays.GetAll();
|
||||||
|
|
||||||
|
var items = new List<UnplannedLessonItem>();
|
||||||
|
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
|
||||||
|
{
|
||||||
|
var slots = _timetableSlots.GetByGroup(group.Id);
|
||||||
|
if (slots.Count == 0) continue;
|
||||||
|
|
||||||
|
for (var date = today; date <= lastDay; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
|
||||||
|
var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
|
||||||
|
if (daySlots.Count == 0) continue;
|
||||||
|
var periodsWithSlot = daySlots.Select(s => s.PeriodNumber).ToHashSet();
|
||||||
|
var dayLessons = _lessons.GetByGroupAndDate(group.Id, date).ToList();
|
||||||
|
var cancelledPeriods = _substitutions.GetByDate(date)
|
||||||
|
.Where(s => s.Kind == SubstitutionKind.Cancelled)
|
||||||
|
.Select(s => s.PeriodNumber).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var slot in daySlots)
|
||||||
|
{
|
||||||
|
if (cancelledPeriods.Contains(slot.PeriodNumber)) continue;
|
||||||
|
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == slot.PeriodNumber);
|
||||||
|
if (lesson is not null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(lesson.Topic)) continue;
|
||||||
|
}
|
||||||
|
else if (IsCoveredByEarlierDoppelstunde(dayLessons, periodsWithSlot, slot.PeriodNumber))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName))
|
||||||
|
UnplannedLessons.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
|
||||||
|
/// bei einer früheren Stundennummer beginnt — gleiches Prinzip wie
|
||||||
|
/// PlanningViewModels.LessonDialogViewModel.RecomputeTimeBudget: ausgehend von der Vorperiode
|
||||||
|
/// wird rückwärts so lange die jeweils vorherige Periode geprüft, wie der Stundenplan dafür
|
||||||
|
/// noch einen Slot hat. Trifft man dabei auf eine Lesson, entscheidet deren Thema (vorhanden =
|
||||||
|
/// Doppelstunde bereits geplant); trifft man auf eine Periode ohne Lesson, wird weiter
|
||||||
|
/// zurückgegangen; bricht die Slot-Kette ab, ohne eine Lesson gefunden zu haben, ist die Periode
|
||||||
|
/// nicht abgedeckt.</summary>
|
||||||
|
private static bool IsCoveredByEarlierDoppelstunde(
|
||||||
|
List<Lesson> dayLessons, HashSet<int> periodsWithSlot, int periodNumber)
|
||||||
|
{
|
||||||
|
for (var period = periodNumber - 1; periodsWithSlot.Contains(period); period--)
|
||||||
|
{
|
||||||
|
var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == period);
|
||||||
|
if (lesson is not null) return !string.IsNullOrWhiteSpace(lesson.Topic);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in
|
||||||
|
/// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein
|
||||||
|
/// Service-Aufwand für eine dritte Fundstelle.</summary>
|
||||||
|
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
|
||||||
|
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
|
||||||
|
|
||||||
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
|
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
|
||||||
|
|
||||||
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
|
||||||
@@ -330,7 +429,7 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
|
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
|
||||||
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
|
||||||
"corrections" => "Offene Korrekturen", "alerts" => "Auffälligkeiten",
|
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
|
||||||
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
|
||||||
"groups" => "Meine Lerngruppen", _ => key,
|
"groups" => "Meine Lerngruppen", _ => key,
|
||||||
};
|
};
|
||||||
@@ -524,6 +623,8 @@ public partial class DashboardViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
|
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
|
||||||
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
|
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
|
||||||
|
[RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
|
||||||
|
{ if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
|
||||||
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
|
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
|
||||||
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
|
||||||
[RelayCommand] private void Refresh() => Load();
|
[RelayCommand] private void Refresh() => Load();
|
||||||
@@ -685,6 +786,16 @@ public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string tit
|
|||||||
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
|
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
|
||||||
|
{
|
||||||
|
public Guid GroupId { get; } = groupId;
|
||||||
|
public string GroupName { get; } = groupName;
|
||||||
|
public DateOnly Date { get; } = date;
|
||||||
|
public int PeriodNumber { get; } = periodNumber;
|
||||||
|
public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
|
||||||
|
public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
|
||||||
|
}
|
||||||
|
|
||||||
public enum AlertSeverity { Medium, High }
|
public enum AlertSeverity { Medium, High }
|
||||||
|
|
||||||
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
|
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
|
||||||
|
|||||||
@@ -810,6 +810,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int? _hoursPerWeek;
|
[ObservableProperty] private int? _hoursPerWeek;
|
||||||
[ObservableProperty] private bool _isOwnClass;
|
[ObservableProperty] private bool _isOwnClass;
|
||||||
[ObservableProperty] private bool _isDifferentiated;
|
[ObservableProperty] private bool _isDifferentiated;
|
||||||
|
[ObservableProperty] private bool _requiresLessonPlanning = true;
|
||||||
[ObservableProperty] private string _nameError = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
[ObservableProperty] private string _gradeLevelError = "";
|
[ObservableProperty] private string _gradeLevelError = "";
|
||||||
|
|
||||||
@@ -848,6 +849,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
HoursPerWeek = group.HoursPerWeek;
|
HoursPerWeek = group.HoursPerWeek;
|
||||||
IsOwnClass = group.IsOwnClass;
|
IsOwnClass = group.IsOwnClass;
|
||||||
IsDifferentiated = group.IsDifferentiated;
|
IsDifferentiated = group.IsDifferentiated;
|
||||||
|
RequiresLessonPlanning = group.RequiresLessonPlanning;
|
||||||
OnPropertyChanged(nameof(DialogTitle));
|
OnPropertyChanged(nameof(DialogTitle));
|
||||||
OnPropertyChanged(nameof(SaveButtonText));
|
OnPropertyChanged(nameof(SaveButtonText));
|
||||||
}
|
}
|
||||||
@@ -893,6 +895,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
Result.HoursPerWeek = HoursPerWeek;
|
Result.HoursPerWeek = HoursPerWeek;
|
||||||
Result.IsOwnClass = IsOwnClass;
|
Result.IsOwnClass = IsOwnClass;
|
||||||
Result.IsDifferentiated = IsDifferentiated;
|
Result.IsDifferentiated = IsDifferentiated;
|
||||||
|
Result.RequiresLessonPlanning = RequiresLessonPlanning;
|
||||||
_groups.Save(Result);
|
_groups.Save(Result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -711,9 +711,35 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
|
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
|
||||||
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
|
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DateText = SuggestNextLessonDate().ToString("dd.MM.yyyy");
|
||||||
|
}
|
||||||
RecomputeTimes();
|
RecomputeTimes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vorbelegung für eine neue Stunde: statt des sonst über den Feld-Default eingesetzten
|
||||||
|
/// heutigen Datums (das an einem beliebigen Wochentag steht und die Doppelstunden-Erkennung
|
||||||
|
/// in <see cref="RecomputeTimeBudget"/> stillschweigend auf eine Einzelperiode zurückfallen
|
||||||
|
/// lässt, wenn der Wochentag nicht zufällig passt) der nächste laut Stundenplan (4.3) für
|
||||||
|
/// diese Gruppe passende Wochentag — ab der letzten Stunde dieser Einheit, oder ab heute, wenn
|
||||||
|
/// noch keine Stunde in dieser Einheit existiert oder die letzte in der Vergangenheit liegt.
|
||||||
|
/// Ohne Stundenplan-Einträge für die Gruppe (z.B. Klassenrat) bleibt es beim heutigen Datum.
|
||||||
|
/// </summary>
|
||||||
|
private DateOnly SuggestNextLessonDate()
|
||||||
|
{
|
||||||
|
var weekdays = _timetableSlots.GetByGroup(_groupId).Select(s => s.Weekday).ToHashSet();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
if (weekdays.Count == 0) return today;
|
||||||
|
|
||||||
|
var lastLessonDate = _lessons.GetByUnit(_unitId).Select(l => l.Date)
|
||||||
|
.DefaultIfEmpty(today.AddDays(-1)).Max();
|
||||||
|
var candidate = lastLessonDate >= today ? lastLessonDate.AddDays(1) : today;
|
||||||
|
while (!weekdays.Contains(candidate.DayOfWeek)) candidate = candidate.AddDays(1);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddPhase()
|
private void AddPhase()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public static class TaskCategoryDisplay
|
|||||||
TaskCategory.Admin => "Verwaltung",
|
TaskCategory.Admin => "Verwaltung",
|
||||||
TaskCategory.Meeting => "Besprechung",
|
TaskCategory.Meeting => "Besprechung",
|
||||||
TaskCategory.Other => "Sonstiges",
|
TaskCategory.Other => "Sonstiges",
|
||||||
|
TaskCategory.Teaching => "Unterricht",
|
||||||
_ => c.ToString(),
|
_ => c.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,6 +334,15 @@ public partial class TimeTrackingViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly ITimeEntryRepository _entries;
|
private readonly ITimeEntryRepository _entries;
|
||||||
private readonly IWorkTaskRepository _tasks;
|
private readonly IWorkTaskRepository _tasks;
|
||||||
|
private readonly ITimetableSlotRepository _timetableSlots;
|
||||||
|
private readonly PeriodScheduleService _periodSchedule;
|
||||||
|
|
||||||
|
// Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch
|
||||||
|
// nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible
|
||||||
|
// Puffer für den Unterrichtszeit-Vorschlag. Der Vorschlag füllt nur den Nacherfassen-Dialog
|
||||||
|
// vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
|
||||||
|
private const int BufferBeforeFirstPeriodMinutes = 15;
|
||||||
|
private const int BufferAfterLastPeriodMinutes = 10;
|
||||||
|
|
||||||
public const string NoTaskOption = "Keine Aufgabe";
|
public const string NoTaskOption = "Keine Aufgabe";
|
||||||
|
|
||||||
@@ -351,12 +361,24 @@ public partial class TimeTrackingViewModel : ObservableObject
|
|||||||
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
|
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
|
||||||
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
|
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
|
||||||
|
|
||||||
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
|
/// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
|
||||||
|
/// "Unterrichtszeit übernehmen"-Button überhaupt sichtbar ist.
|
||||||
|
public bool HasTeachingTimeSuggestionToday => ComputeTodaysTeachingWindow() is not null;
|
||||||
|
|
||||||
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks)
|
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
|
||||||
|
/// Wie OnAddEntry, aber öffnet den Dialog mit vorbefüllter Kategorie "Unterricht" und den
|
||||||
|
/// laut Stundenplan/Stundenraster vorgeschlagenen Beginn-/Ende-Zeiten - eine echte Bestätigung
|
||||||
|
/// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer-
|
||||||
|
/// Konstanten oben).
|
||||||
|
public Func<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
|
||||||
|
|
||||||
|
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
|
||||||
|
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
|
||||||
{
|
{
|
||||||
_entries = entries;
|
_entries = entries;
|
||||||
_tasks = tasks;
|
_tasks = tasks;
|
||||||
|
_timetableSlots = timetableSlots;
|
||||||
|
_periodSchedule = periodSchedule;
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +464,38 @@ public partial class TimeTrackingViewModel : ObservableObject
|
|||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SuggestTeachingTime()
|
||||||
|
{
|
||||||
|
if (OnSuggestTeachingTime is null || ComputeTodaysTeachingWindow() is not var (start, end)) return;
|
||||||
|
var result = await OnSuggestTeachingTime(start, end);
|
||||||
|
if (result is null) return;
|
||||||
|
_entries.Save(result);
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht
|
||||||
|
/// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten
|
||||||
|
/// Puffer erweitert. Ohne Stundenplan-Eintrag heute oder ohne im Stundenraster hinterlegte
|
||||||
|
/// Zeiten gibt es keinen Vorschlag (null) statt einer erfundenen Zeit.
|
||||||
|
/// </summary>
|
||||||
|
private (TimeOnly Start, TimeOnly End)? ComputeTodaysTeachingWindow()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today).DayOfWeek;
|
||||||
|
var periodTimes = _timetableSlots.GetAll()
|
||||||
|
.Where(s => s.Weekday == today)
|
||||||
|
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
|
||||||
|
.Where(t => t is not null)
|
||||||
|
.Select(t => t!.Value)
|
||||||
|
.ToList();
|
||||||
|
if (periodTimes.Count == 0) return null;
|
||||||
|
|
||||||
|
var start = periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes);
|
||||||
|
var end = periodTimes.Max(t => t.End).AddMinutes(BufferAfterLastPeriodMinutes);
|
||||||
|
return (start, end);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void DeleteEntry(TimeEntryListItem? item)
|
private void DeleteEntry(TimeEntryListItem? item)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
|
||||||
|
|
||||||
<!-- Heutige Stunden -->
|
<!-- Heutige Stunden -->
|
||||||
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
|
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
|
||||||
@@ -415,6 +415,36 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Ungeplante Stunden -->
|
||||||
|
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
|
||||||
|
IsVisible="{Binding UnplannedCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
CornerRadius="8" Padding="16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="UNGEPLANTE STUNDEN" FontSize="11" FontWeight="Bold"
|
||||||
|
Opacity="0.5" Margin="0,0,0,10"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding UnplannedLessons}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:UnplannedLessonItem">
|
||||||
|
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUnplannedLessonCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Text="{Binding Display}" FontSize="13"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
|
||||||
|
VerticalAlignment="Center" Opacity="0.6"/>
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Keine ungeplanten Stunden." Classes="emptyhint"
|
||||||
|
IsVisible="{Binding !UnplannedLessons.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Auffälligkeiten (9.5) -->
|
<!-- Auffälligkeiten (9.5) -->
|
||||||
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
|
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
|
||||||
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
|
||||||
|
|||||||
@@ -74,6 +74,9 @@
|
|||||||
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
|
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
|
||||||
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
|
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
|
||||||
|
|
||||||
|
<CheckBox Content="Benötigt Unterrichtsplanung" IsChecked="{Binding RequiresLessonPlanning}"
|
||||||
|
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung "Ungeplante Stunden" aus."/>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
|||||||
@@ -66,9 +66,13 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Einträge / Nacherfassung (6.2.2) -->
|
<!-- Einträge / Nacherfassung (6.2.2) -->
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
|
||||||
<Button Grid.Column="1" Content="+ Nacherfassen" Command="{Binding AddEntryCommand}"/>
|
<Button Grid.Column="1" Content="🕓 Unterrichtszeit heute übernehmen" Margin="0,0,8,0"
|
||||||
|
Command="{Binding SuggestTeachingTimeCommand}"
|
||||||
|
IsVisible="{Binding HasTeachingTimeSuggestionToday}"
|
||||||
|
ToolTip.Tip="Schlägt Beginn/Ende anhand des heutigen Stundenplans vor (mit etwas Puffer davor/danach) - im Dialog vor dem Speichern noch anpassbar."/>
|
||||||
|
<Button Grid.Column="2" Content="+ Nacherfassen" Command="{Binding AddEntryCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding WeekEntries}">
|
<ItemsControl ItemsSource="{Binding WeekEntries}">
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ public partial class TimeTrackingView : UserControl
|
|||||||
{
|
{
|
||||||
base.OnDataContextChanged(e);
|
base.OnDataContextChanged(e);
|
||||||
if (DataContext is TimeTrackingViewModel vm)
|
if (DataContext is TimeTrackingViewModel vm)
|
||||||
vm.OnAddEntry = ShowAddEntryDialog;
|
{
|
||||||
|
vm.OnAddEntry = () => ShowAddEntryDialog();
|
||||||
|
vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht",
|
||||||
|
prefillStart: start, prefillEnd: end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<TimeEntry?> ShowAddEntryDialog()
|
private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null,
|
||||||
|
TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
|
||||||
{
|
{
|
||||||
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;
|
||||||
@@ -25,6 +30,9 @@ public partial class TimeTrackingView : UserControl
|
|||||||
var tasks = App.Services.GetRequiredService<IWorkTaskRepository>()
|
var tasks = App.Services.GetRequiredService<IWorkTaskRepository>()
|
||||||
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||||
var vm = new AddTimeEntryDialogViewModel(tasks);
|
var vm = new AddTimeEntryDialogViewModel(tasks);
|
||||||
|
if (prefillCategory is not null) vm.SelectedCategory = prefillCategory;
|
||||||
|
if (prefillStart is { } s) vm.StartTimeText = s.ToString("HH:mm");
|
||||||
|
if (prefillEnd is { } e) vm.EndTimeText = e.ToString("HH:mm");
|
||||||
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||||
await dialog.ShowDialog<bool>(owner);
|
await dialog.ShowDialog<bool>(owner);
|
||||||
return vm.Result;
|
return vm.Result;
|
||||||
|
|||||||
@@ -460,6 +460,20 @@ Redesign:
|
|||||||
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
|
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
|
||||||
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
|
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
|
||||||
|
|
||||||
|
**Nachtrag — Datum-Vorbelegung beim manuellen "+ Stunde"-Dialog:** Nutzer-Bug-Report: eine
|
||||||
|
manuell angelegte Doppelstunde zeigte immer 45 statt 90 Minuten Zeitbedarf. Ursache war kein
|
||||||
|
Fehler in der Doppelstunden-Erkennung (Nachtrag zu 4.2.2 oben) selbst, sondern dass `DateText`
|
||||||
|
im `LessonDialogViewModel` beim Neuanlegen immer auf "heute" vorbelegt war — traf dieses Datum
|
||||||
|
nicht auf den Wochentag der eingetragenen Stundennummer im Stundenplan, lief die
|
||||||
|
weekday-basierte Nachschlage-Logik für die Folgeperiode ins Leere und die Erkennung blieb
|
||||||
|
stumm bei 45 Minuten, ohne dass das im UI ersichtlich war. `LessonDialogViewModel.
|
||||||
|
SuggestNextLessonDate()` ersetzt jetzt den "heute"-Default beim Neuanlegen: sie ermittelt aus
|
||||||
|
`TimetableSlot` alle Wochentage, an denen die Gruppe laut Stundenplan Unterricht hat, nimmt das
|
||||||
|
späteste bereits existierende `Lesson.Date` der Einheit (oder heute, falls die Einheit noch
|
||||||
|
leer ist) und rollt von dort vorwärts auf den nächsten passenden Wochentag. Fehlt ein
|
||||||
|
Stundenplan-Eintrag für die Gruppe, bleibt "heute" als Fallback erhalten (keine Verhaltens-
|
||||||
|
änderung für Gruppen ohne Stundenplan).
|
||||||
|
|
||||||
### 4.3 Stundenplan
|
### 4.3 Stundenplan
|
||||||
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
|
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
|
||||||
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
|
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
|
||||||
@@ -1101,6 +1115,22 @@ Auswertung (6.3.3), der am noch nicht existierenden Kapitel 11 (Export-Infrastru
|
|||||||
Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
|
Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
|
||||||
`ITimeEntryRepository`.
|
`ITimeEntryRepository`.
|
||||||
|
|
||||||
|
**Nachtrag — Komfort-Funktion "Unterrichtszeit heute übernehmen":** Nutzer-Wunsch: Unterrichtszeit
|
||||||
|
soll nicht komplett automatisch erfasst werden ("weiß nicht, ob das rechtlich sinnvoll ist"),
|
||||||
|
aber ein Vorschlag mit Rückfrage ist erwünscht — man beginnt vor der ersten Stunde und geht nicht
|
||||||
|
sofort nach der letzten. Neuer Button "🕓 Unterrichtszeit heute übernehmen" in
|
||||||
|
`TimeTrackingView` (nur sichtbar, wenn die Gruppe heute laut Stundenplan überhaupt Unterricht
|
||||||
|
hat — `TimeTrackingViewModel.HasTeachingTimeSuggestionToday`). `ComputeTodaysTeachingWindow()`
|
||||||
|
ermittelt aus allen `TimetableSlot`-Einträgen des heutigen Wochentags (`ITimetableSlotRepository`)
|
||||||
|
und dem Stundenraster (6.2/4.2, `PeriodScheduleService`) die früheste Start- und späteste
|
||||||
|
Endzeit des Tages, zieht einen festen Puffer davor (`BufferBeforeFirstPeriodMinutes = 15`) und
|
||||||
|
danach (`BufferAfterLastPeriodMinutes = 10`) ab/dazu. Klick öffnet den bestehenden
|
||||||
|
`AddTimeEntryDialog` (6.2.2) mit Kategorie "Unterricht" sowie Start/Ende vorbefüllt — bewusst
|
||||||
|
weiterhin ein normaler, vom Nutzer bestätigter Nacherfassungs-Dialog, keine automatische
|
||||||
|
Buchung ohne Blick darauf. Neuer `TaskCategory.Teaching`-Wert (`TaskCategoryDisplay.Label`:
|
||||||
|
"Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu
|
||||||
|
verschieben.
|
||||||
|
|
||||||
### 6.3 Auswertung
|
### 6.3 Auswertung
|
||||||
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
|
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
|
||||||
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat
|
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat
|
||||||
@@ -1294,6 +1324,46 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
|
|||||||
Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
|
Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
|
||||||
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
|
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
|
||||||
werden, sobald dafür ein eigenes Termine-Modell existiert.
|
werden, sobald dafür ein eigenes Termine-Modell existiert.
|
||||||
|
- [x] **9.9** Kachel "Ungeplante Stunden": erinnert an Stunden ohne Thema, für die noch kein
|
||||||
|
Verlaufsplan existiert. Nutzer-Wunsch: der zeitliche Vorgriff soll sinnvoll begrenzt sein
|
||||||
|
(morgens sollte wenigstens ein Stundenthema bereits bekannt sein) und einzelne Gruppen ohne
|
||||||
|
inhaltlichen Verlaufsplan (Klassenrat, Willkommenskreis) sollen sich ausnehmen lassen.
|
||||||
|
**Umsetzung:** `DashboardViewModel.LoadUnplannedLessons` läuft für jede Gruppe mit
|
||||||
|
`LearningGroup.RequiresLessonPlanning == true` (neues Feld, Default `true` — bestehende
|
||||||
|
Gruppen erhalten es beim LiteDB-Deserialisieren automatisch, keine explizite Migration
|
||||||
|
nötig) über die `TimetableSlot`-Einträge von heute bis morgen
|
||||||
|
(`UnplannedLessonsLookaheadDays = 1`, Nutzer hat sich im Dialog explizit für "Heute +
|
||||||
|
morgen" statt eines längeren Vorgriffs entschieden) und meldet jeden Termin, zu dem entweder
|
||||||
|
keine `Lesson` existiert oder deren `Topic` leer ist. Ferientage/Feiertage werden wie überall
|
||||||
|
sonst übersprungen (`IsFreeDay`, dieselbe Prüfung wie in `TimetableViewModel`/
|
||||||
|
`GenerateLessonSeriesDialogViewModel` — bewusst dupliziert statt in einen gemeinsamen
|
||||||
|
Service extrahiert, konsistent mit dem bestehenden Muster). Klick auf einen Eintrag springt
|
||||||
|
in die Lerngruppe, Tab "Planung" (`OnNavigateToUnplannedLesson`,
|
||||||
|
`NavigateToGroupDetail(id, 6)`), damit das Thema direkt ergänzt werden kann — bewusst
|
||||||
|
anderes Sprungziel als die "Heutige Stunden"-Kachel (9.2, Tab "Mitarbeit"). Deaktivierbar je
|
||||||
|
Gruppe über eine neue Checkbox "Benötigt Unterrichtsplanung" im Gruppen-Stammdaten-Dialog.
|
||||||
|
|
||||||
|
**Nachtrag zu 9.9 (Bugfix Doppelstunden-Erkennung):** Nutzer-Bug-Report: eine als Doppelstunde
|
||||||
|
geplante 3./4. Stunde (eine `Lesson` mit `LessonNumber = 3` und Thema, die laut Stundenraster
|
||||||
|
90 Minuten abdeckt, siehe Nachtrag zu 4.2.2) wurde auf dem Dashboard trotzdem als "4. Stunde noch
|
||||||
|
ungeplant" gemeldet, weil `LoadUnplannedLessons` je `TimetableSlot` stur auf eine `Lesson` mit
|
||||||
|
exakt derselben `LessonNumber` prüfte — für Periode 4 gibt es bei einer Doppelstunde aber bewusst
|
||||||
|
keine eigene `Lesson`. Neue Hilfsmethode `IsCoveredByEarlierDoppelstunde` wendet dieselbe
|
||||||
|
Rückwärts-Erkennung wie `LessonDialogViewModel.RecomputeTimeBudget` an: ausgehend von der
|
||||||
|
Vorperiode wird so lange rückwärts geprüft, wie der Stundenplan dort ebenfalls einen Slot hat;
|
||||||
|
trifft man auf eine `Lesson` mit Thema, gilt die Periode als abgedeckt, trifft man auf eine
|
||||||
|
`Lesson` ohne Thema, bricht die Suche ab (diese Periode bleibt eine eigene, ungeplante Stunde).
|
||||||
|
Eine eigene, direkt zugeordnete `Lesson` ohne Thema wird also weiterhin gemeldet — nur die
|
||||||
|
implizit mitabgedeckte Folgeperiode einer bereits geplanten Doppelstunde nicht mehr.
|
||||||
|
|
||||||
|
**Nachtrag zu 9.9 (Bugfix Stundenausfall):** Zweiter Nutzer-Bug-Report: eine laut Stundenplan
|
||||||
|
regulär stattfindende, aber per Vertretungs-Dialog als `SubstitutionKind.Cancelled` eingetragene
|
||||||
|
Stunde (4.3, "Stundenausfall") wurde trotzdem als ungeplant gemeldet, obwohl sie an diesem Tag
|
||||||
|
gar nicht stattfindet. `LoadUnplannedLessons` prüft jetzt zusätzlich `ISubstitutionEntryRepository.
|
||||||
|
GetByDate(date)` auf `Cancelled`-Einträge und überspringt betroffene Stundennummern vollständig —
|
||||||
|
gleiche Abfrage (Datum + Stundennummer, bewusst ohne Gruppenbezug, siehe `SubstitutionEntry.
|
||||||
|
PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des
|
||||||
|
Stundenplans.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user