diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 8cf36e3..dcf5ff3 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -357,6 +357,20 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository public void Save(Unit u) { ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId); + // Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine + // zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog + // kündigt diese automatische Ablösung vorher sichtbar an. + if (u.Status == UnitStatus.Active) + { + foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId && + x.Status == UnitStatus.Active && x.Id != u.Id)) + { + previous.Status = UnitStatus.Completed; + previous.UpdatedAt = DateTime.UtcNow; + db.Units.Upsert(previous); + db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous); + } + } u.UpdatedAt = DateTime.UtcNow; db.Units.Upsert(u); db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u); diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 84a1e6e..e9af6a8 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -8,6 +8,37 @@ namespace LehrerApp.Desktop.Tests; public sealed class ClassTeacherViewModelsTests { + [Fact] + public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus() + { + var month = new DateOnly(2026, 9, 1); + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 2), "Müller Ada", 1, 0, 12, + ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Schmidt Ben", 2, 2, 90, + ["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), + }; + var register = new[] + { + new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 3), "Deu", "Schmidt Ben", "test", + "Störung", "Negativ", "Unterricht massiv gestört"), + new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 4), "Mathe", "Müller Ada", "test", + "Hausaufgaben", "Negativ", "Hausaufgaben fehlen"), + }; + + var days = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: false); + + Assert.Equal(0, days.Count % 5); + Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V"); + Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U"); + Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!"); + Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H"); + + var withHomework = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: true); + Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H"); + } + [Fact] public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() { diff --git a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs index ea6789f..8a8e53c 100644 --- a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs @@ -45,6 +45,21 @@ public class PlanningTabViewModelTests Assert.Contains("2 / 3", summary.ProgressText); } + [Fact] + public void Initialize_WaehltLaufendeEinheitAlsArbeitskontext() + { + var (vm, units, _, groupId) = BuildScenario(); + units.Add(new Unit { GroupId = groupId, Title = "Früher", Status = UnitStatus.Completed, + StartDate = new DateOnly(2025, 8, 1) }); + var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active, + StartDate = new DateOnly(2025, 9, 1) }; + units.Add(active); + + vm.Initialize(groupId); + + Assert.Equal(active.Id, vm.SelectedUnit?.Id); + } + [Fact] public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag() { diff --git a/LehrerApp.Desktop.Tests/UnitDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/UnitDialogViewModelTests.cs index a8d1bbd..e2eb81a 100644 --- a/LehrerApp.Desktop.Tests/UnitDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/UnitDialogViewModelTests.cs @@ -25,4 +25,28 @@ public sealed class UnitDialogViewModelTests Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay); } + + [Fact] + public void Laufend_SetztBisherigeLaufendeEinheitAufAbgeschlossenUndWarntVorher() + { + var groupId = Guid.NewGuid(); + var units = new FakeUnits(); + var previous = new LehrerApp.Core.Models.Unit + { + GroupId = groupId, Title = "Bruchrechnung", Status = LehrerApp.Core.Models.UnitStatus.Active, + }; + units.Add(previous); + var vm = new UnitDialogViewModel(units, new FakeCompetencyDomains(), + groupId, null, 6, "6a", "Mathematik", editingUnit: null) + { + Title = "Prozentrechnung", + StatusName = "Laufend", + }; + + Assert.Contains("Bruchrechnung", vm.StatusNotice); + vm.SaveCommand.Execute(null); + + Assert.Equal(LehrerApp.Core.Models.UnitStatus.Completed, previous.Status); + Assert.Single(units.GetByGroup(groupId), u => u.Status == LehrerApp.Core.Models.UnitStatus.Active); + } } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index a719878..4707ad7 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -119,6 +119,24 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); } +public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework } + +/// Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht. +public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label, + string ColorHex, ClassTeacherCalendarEventKind Kind) +{ + public string Tooltip => $"{StudentName}: {Label}"; +} + +/// Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter. +public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList Events) +{ + public bool IsPlaceholder => Date is null; + public string DayLabel => Date?.ToString("dd.") ?? ""; + public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? ""; + public bool HasEvents => Events.Count > 0; +} + /// /// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu /// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der @@ -149,6 +167,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public ObservableCollection AbsenceEntries { get; } = []; public ObservableCollection CategoryAggregates { get; } = []; public ObservableCollection OwnDocumentationEntries { get; } = []; + public ObservableCollection CalendarDays { get; } = []; /// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden). [ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry; @@ -160,6 +179,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject /// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen. [ObservableProperty] private string _studentFilter = ""; [ObservableProperty] private int _quickRangeIndex = 1; + [ObservableProperty] private bool _showMonthlyCalendar; + [ObservableProperty] private bool _includeHomeworkInCalendar; + [ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1); /// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation. [ObservableProperty] private bool _showOwnDocumentation; [ObservableProperty] private int _ownDocumentationCount; @@ -174,6 +196,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public bool HasEntries => Entries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0; + public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries; public bool HasCategoryAggregates => CategoryAggregates.Count > 0; public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0; /// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen () @@ -182,6 +205,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus); public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter) ? "Alle Schüler*innen" : StudentFilter; + public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", + System.Globalization.CultureInfo.GetCultureInfo("de-DE")); public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation, IStudentRepository students, ClassTeacherCasesViewModel cases) @@ -198,6 +223,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject StudentFilter = ""; Entries.Clear(); AbsenceEntries.Clear(); + CalendarDays.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear(); _rosterMatches = []; @@ -208,6 +234,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel)); partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates)); + partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty)); + partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar(); + partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel)); partial void OnQuickRangeIndexChanged(int value) { @@ -230,6 +259,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject [RelayCommand] private Task ApplyFilter() => LoadInternal(forceRefresh: false); + [RelayCommand] private void ShowAbsenceList() => ShowMonthlyCalendar = false; + + [RelayCommand] + private async Task ShowCalendar() + { + ShowMonthlyCalendar = true; + await LoadCalendarMonth(forceRefresh: false); + } + + [RelayCommand] + private async Task PreviousCalendarMonth() + { + CalendarMonth = CalendarMonth.AddMonths(-1); + await LoadCalendarMonth(forceRefresh: false); + } + + [RelayCommand] + private async Task NextCalendarMonth() + { + CalendarMonth = CalendarMonth.AddMonths(1); + await LoadCalendarMonth(forceRefresh: false); + } + + private Task LoadCalendarMonth(bool forceRefresh) + { + StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue)); + var end = CalendarMonth.AddMonths(1).AddDays(-1); + EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue)); + return LoadInternal(forceRefresh); + } + [RelayCommand] private async Task ClearStudentFilter() { @@ -270,6 +330,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) AbsenceEntries.Add(row); + BuildCalendar(); + var localStudents = _students.GetAll(); _rosterMatches = rosterTask.Result .Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents))) @@ -289,6 +351,67 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject finally { Busy = false; NotifyListState(); } } + private void BuildCalendar() + { + CalendarDays.Clear(); + foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar)) + CalendarDays.Add(day); + } + + /// Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative + /// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage. + public static IReadOnlyList BuildCalendarDays(DateOnly month, + IReadOnlyList absences, + IReadOnlyList registerEntries, bool includeHomework) + { + var first = new DateOnly(month.Year, month.Month, 1); + var last = first.AddMonths(1).AddDays(-1); + var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7)); + var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7); + var result = new List(); + + for (var date = gridStart; date <= gridEnd; date = date.AddDays(1)) + { + if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue; + if (date.Month != first.Month) + { + result.Add(new ClassTeacherCalendarDay(null, [])); + continue; + } + + var events = new List(); + foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName)) + { + var (code, label, color, kind) = absence.IsUnexcused + ? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused) + : absence.IsLate + ? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late) + : absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase) + ? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused) + : ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent); + events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind)); + } + + foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName)) + { + var isHomework = ContainsHomework(entry); + if (isHomework && !includeHomework) continue; + if (!entry.IsDangerStatus && !isHomework) continue; + events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName, + isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag", + isHomework ? "#6A1B9A" : "#8E24AA", + isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister)); + } + + result.Add(new ClassTeacherCalendarDay(date, events)); + } + return result; + } + + private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) => + entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true || + entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true; + /// Baut aus und dem zuletzt /// geladenen Zeitraum neu auf — separat von , damit Anlegen/Bearbeiten/ /// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche @@ -419,6 +542,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject { OnPropertyChanged(nameof(HasEntries)); OnPropertyChanged(nameof(HasAbsenceEntries)); + OnPropertyChanged(nameof(ShowAbsenceListEmpty)); OnPropertyChanged(nameof(HasCategoryAggregates)); OnPropertyChanged(nameof(ShowCategoryAggregates)); OnPropertyChanged(nameof(HasOwnDocumentationEntries)); diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 82e77d5..ad9e615 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -102,10 +102,10 @@ public partial class PlanningTabViewModel : ObservableObject GradeLevel = group?.GradeLevel ?? 0; SubjectName = SubjectId is Guid sid ? _subjects.GetById(sid)?.Name ?? "" : ""; GroupLabel = group?.Name ?? ""; - LoadUnits(); + LoadUnits(preferActive: true); } - private void LoadUnits() + private void LoadUnits(bool preferActive = false) { var selectedId = SelectedUnit?.Id; Units.Clear(); @@ -128,7 +128,11 @@ public partial class PlanningTabViewModel : ObservableObject KnownMaterials = materials.OrderBy(m => m, StringComparer.CurrentCultureIgnoreCase).ToList(); KnownShorthands = shorthands.OrderBy(s => s, StringComparer.CurrentCultureIgnoreCase).ToList(); - SelectedUnit = Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(); + // Beim ersten Öffnen bzw. beim Wechsel aus dem Stundenplan steht die laufende Einheit im + // Fokus. Eine bewusste Auswahl innerhalb derselben Gruppe bleibt bei Refreshes erhalten. + SelectedUnit = !preferActive && selectedId is not null + ? Units.FirstOrDefault(u => u.Id == selectedId) ?? Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault() + : Units.FirstOrDefault(u => u.Status == UnitStatus.Active) ?? Units.FirstOrDefault(); } partial void OnSelectedUnitChanged(UnitSummary? value) @@ -574,6 +578,7 @@ public partial class UnitDialogViewModel : ObservableObject [ObservableProperty] private string _startDateText = ""; [ObservableProperty] private string _endDateText = ""; [ObservableProperty] private string _statusName = UnitStatusDisplay.Options[0]; + [ObservableProperty] private string _statusNotice = ""; [ObservableProperty] private string _notes = ""; [ObservableProperty] private bool _isCompetencyPanelOpen; [ObservableProperty] private string _titleError = ""; @@ -594,6 +599,20 @@ public partial class UnitDialogViewModel : ObservableObject public string DialogTitle => _editingUnit is null ? "Neue Einheit anlegen" : "Einheit bearbeiten"; public string SaveButtonText => _editingUnit is null ? "Anlegen" : "Speichern"; + partial void OnStatusNameChanged(string value) + { + if (UnitStatusDisplay.FromName(value) != UnitStatus.Active) + { + StatusNotice = ""; + return; + } + + var previous = _units.GetByGroup(_groupId) + .FirstOrDefault(u => u.Status == UnitStatus.Active && u.Id != _editingUnit?.Id); + StatusNotice = previous is null ? "" + : $"„{previous.Title}“ wird beim Speichern automatisch abgeschlossen."; + } + public UnitDialogViewModel(IUnitRepository units, ICompetencyDomainRepository competencyDomains, Guid groupId, Guid? subjectId, int gradeLevel, string groupName, string subjectName, Unit? editingUnit) { @@ -679,6 +698,17 @@ public partial class UnitDialogViewModel : ObservableObject Result.Status = UnitStatusDisplay.FromName(StatusName); Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); Result.Competencies = _competencyCodes; + if (Result.Status == UnitStatus.Active) + { + // Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende + // Einheiten werden nicht verworfen, sondern fachlich sauber abgeschlossen. + foreach (var previous in _units.GetByGroup(_groupId) + .Where(u => u.Status == UnitStatus.Active && u.Id != Result.Id)) + { + previous.Status = UnitStatus.Completed; + _units.Save(previous); + } + } _units.Save(Result); } } diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml index 3e1badf..0ab0add 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml @@ -27,9 +27,20 @@ + + - + @@ -39,7 +50,20 @@ IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/> - + + - -