From aa326cf8b662f35130b1a38f167fba17111c761c Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sat, 26 Sep 2026 11:16:13 +0200 Subject: [PATCH] =?UTF-8?q?Add=20warning=20detail=20popup=20and=20quick=20?= =?UTF-8?q?blocks=20for=20Vorg=C3=A4nge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard warnings (attendance, homework, lateness) get a "Details" flyout with key figures, a list of dates and the compact month calendar from the class teacher overview, now extracted as a shared control. The calendar now aligns days under their weekday (Mo-So header, blank leading cells). Creating Vorgänge and documentation is supported by quick blocks with the current figures, dated standard phrases (e.g. "Klassenleitung informiert") and documentation templates (status snapshot, conversation, informing the class/subject teachers, parent call, parent letter). The class teacher Vorgänge tab uses WebUntis figures (absences across all subjects, class register) for the same blocks and templates. Co-Authored-By: Claude Opus 5.5 --- .../StudentWarningEvidenceTests.cs | 167 ++++++++++++ .../VorgangBausteineTests.cs | 116 +++++++++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../Services/StudentWarningEvidenceService.cs | 122 +++++++++ LehrerApp.Desktop/ViewModels/AttentionItem.cs | 13 +- .../ClassTeacher/ClassTeacherCaseFacts.cs | 105 ++++++++ .../ClassTeacherCasesViewModel.cs | 84 +++++- .../ClassTeacher/VorgangViewModels.cs | 77 +++++- .../ViewModels/DashboardViewModel.cs | 15 +- .../StudentWarningDetailViewModel.cs | 158 +++++++++++ .../Students/DocumentationViewModels.cs | 8 +- .../Students/StudentVorgaengeViewModel.cs | 57 +++- .../ViewModels/Students/VorgangTemplates.cs | 246 ++++++++++++++++++ .../ClassTeacher/ClassTeacherCasesView.axaml | 34 ++- .../ClassTeacherCasesView.axaml.cs | 9 +- .../ClassTeacherOverviewView.axaml | 42 +-- .../Views/ClassTeacher/VorgangDialog.axaml | 41 ++- .../Views/Dashboard/DashboardView.axaml | 40 ++- .../Views/Shared/CompactMonthCalendar.axaml | 67 +++++ .../Shared/CompactMonthCalendar.axaml.cs | 78 ++++++ .../Shared/StudentWarningDetailView.axaml | 70 +++++ .../Shared/StudentWarningDetailView.axaml.cs | 8 + .../Students/StudentVorgaengeTabView.axaml | 34 ++- .../Students/StudentVorgaengeTabView.axaml.cs | 9 +- TODO.md | 45 ++++ 25 files changed, 1571 insertions(+), 75 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs create mode 100644 LehrerApp.Desktop.Tests/VorgangBausteineTests.cs create mode 100644 LehrerApp.Desktop/Services/StudentWarningEvidenceService.cs create mode 100644 LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs create mode 100644 LehrerApp.Desktop/ViewModels/StudentWarningDetailViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Students/VorgangTemplates.cs create mode 100644 LehrerApp.Desktop/Views/Shared/CompactMonthCalendar.axaml create mode 100644 LehrerApp.Desktop/Views/Shared/CompactMonthCalendar.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Shared/StudentWarningDetailView.axaml create mode 100644 LehrerApp.Desktop/Views/Shared/StudentWarningDetailView.axaml.cs diff --git a/LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs b/LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs new file mode 100644 index 0000000..631dedd --- /dev/null +++ b/LehrerApp.Desktop.Tests/StudentWarningEvidenceTests.cs @@ -0,0 +1,167 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels; +using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für die Belege hinter den klassenübergreifenden Dashboard-Warnungen: das Detail-Popup +/// (StudentWarningDetailViewModel), die Schnellbausteine im Vorgang-Dialog und die +/// Dokumentationsvorlagen im schülerbezogenen Vorgänge-Tab. Alle Termine liegen ab +/// Schuljahresbeginn, damit die Tests unabhängig vom Ausführungsdatum im laufenden Schuljahr landen. +public sealed class StudentWarningEvidenceTests +{ + private readonly Student _student = new() { FirstName = "Anna", LastName = "Beispiel" }; + private readonly LearningGroup _group = new() { Name = "9c Mathe" }; + private readonly List _sessions = []; + private readonly FakeEntries _entries = new(); + private readonly DateOnly _start; + + public StudentWarningEvidenceTests() + { + var sy = new SchoolYearService(); + _start = sy.SchoolYearStart(sy.CurrentSchoolYear()); + } + + private void AddEntry(int dayOffset, AttendanceStatus? attendance = null, HomeworkStatus? homework = null) + { + var session = new ParticipationSession { GroupId = _group.Id, Date = _start.AddDays(dayOffset) }; + _sessions.Add(session); + _entries.Add(new ParticipationEntry + { SessionId = session.Id, StudentId = _student.Id, Attendance = attendance, Homework = homework }); + } + + private StudentWarningEvidenceService BuildService() => new(new FakeStudents([_student]), + new FakeSessions(_sessions), _entries, new FakeGroups([_group]), new SchoolYearService(), + new AttendanceBalanceService()); + + [Fact] + public void Build_ZaehltWieDieDashboardWarnungen() + { + AddEntry(0, AttendanceStatus.Present); + AddEntry(1, AttendanceStatus.Excused); + AddEntry(2, AttendanceStatus.Unexcused); + AddEntry(3, AttendanceStatus.ExcusePending); + AddEntry(4, AttendanceStatus.OtherSchoolEvent); + AddEntry(5, AttendanceStatus.Late, HomeworkStatus.MissingOpen); + AddEntry(6, AttendanceStatus.SignificantlyLate, HomeworkStatus.Completed); + + var e = BuildService().Build(_student.Id); + + Assert.Equal(7, e.Balance.TotalChecked); + Assert.Equal(3, e.Absences.Count); // Schulveranstaltung zählt nicht als Fehlzeit + Assert.Equal(["A", "U", "E"], e.Absences.Select(a => a.Code)); // neueste zuerst + Assert.Equal(2, e.Lateness.Count); + Assert.Equal(1, e.SignificantlyLateCount); + Assert.Equal("9c Mathe", Assert.Single(e.MissingHomework).GroupName); + Assert.Equal("Anna Beispiel", e.StudentNaturalName); + } + + [Fact] + public void QuickBlocks_NurBausteineMitInhalt() + { + AddEntry(0, AttendanceStatus.Unexcused); + AddEntry(1, AttendanceStatus.Present); + + var blocks = StudentEvidenceText.QuickBlocks(BuildService().Build(_student.Id)); + + Assert.Equal(["Fehlquote", "Fehltermine"], blocks.Select(b => b.Label)); + Assert.Contains("Fehlquote 50 %", blocks[0].Text); + Assert.Contains("9c Mathe", blocks[1].Text); + } + + [Fact] + public void VorgangDialog_InsertQuickBlock_HaengtAnStattZuErsetzen() + { + var vm = new VorgangDialogViewModel([new StudentOption(_student.Id, "Anna")], null, + [new VorgangQuickBlock("Fehlquote", "Stand: 25 %")]) { Description = "Eigene Notiz" }; + + vm.InsertQuickBlockCommand.Execute(vm.QuickBlocks[0]); + + Assert.Equal("Eigene Notiz\n\nStand: 25 %", vm.Description); + Assert.Equal("Vorgang anlegen", vm.DialogTitle); + } + + [Fact] + public void Vorlage_ElternanrufUebernimmtZahlenPassendZumVorgangsTag() + { + for (var i = 0; i < 3; i++) AddEntry(i, AttendanceStatus.Present, HomeworkStatus.MissingOverdue); + var vorgang = new Vorgang { Title = "Hausaufgaben", StudentIds = [_student.Id], Tags = ["Hausaufgaben"] }; + + var doc = VorgangDocumentationTemplates.Build(VorgangDocumentationTemplates.ParentCall, vorgang, + BuildService().Build(_student.Id))!; + + Assert.Equal(DocumentationType.ParentCall, doc.Type); + Assert.Equal(_student.Id, doc.StudentId); + Assert.StartsWith("Aktueller Stand: 3 nicht bzw. unvollständig erledigte Hausaufgaben", doc.ParentCallData!.Points[0].Text); + Assert.DoesNotContain(doc.ParentCallData.Points, p => p.Text.Contains("Fehlquote")); + } + + [Fact] + public void DetailPopup_StartetImMonatDesJuengstenBelegsUndZeigtKuerzel() + { + AddEntry(0, AttendanceStatus.Unexcused); + AddEntry(0, AttendanceStatus.Excused); // zweite Stunde am selben Tag: U gewinnt + AddEntry(1, AttendanceStatus.Late); + + var vm = new StudentWarningDetailViewModel(AttentionKind.Attendance, BuildService().Build(_student.Id)); + + Assert.Equal(new DateOnly(_start.Year, _start.Month, 1), vm.Month); + var first = vm.CalendarDays.Single(d => d.Date == _start); + Assert.Equal("U", first.SignalCode); + Assert.Contains("Krank, entschuldigt", first.Tooltip); + Assert.Equal("V", vm.CalendarDays.Single(d => d.Date == _start.AddDays(1)).SignalCode); + Assert.False(vm.PreviousMonthCommand.CanExecute(null)); // vor Schuljahresbeginn gibt es nichts + Assert.Equal(2, vm.Events.Count); + } + + [Fact] + public async Task HandleWarningNavigation_BelegtBeschreibungMitAktuellemStandVor() + { + for (var i = 0; i < 4; i++) AddEntry(i, AttendanceStatus.Unexcused); + var vm = new StudentVorgaengeViewModel(new FakeVorgangRepository(), new FakeDocumentation(), + new FakeStudents([_student]), BuildService()); + vm.Load(_student.Id); + vm.OnConfirmCreateForWarning = _ => Task.FromResult(true); + Vorgang? draft = null; + vm.OnEditVorgang = (_, editing) => { draft = editing; return Task.FromResult(null); }; + + await vm.HandleWarningNavigation("Absentismus", "Fehlzeiten"); + + Assert.Contains("Fehlquote 100 %", draft!.Description); + Assert.Contains("Fehltermine:", draft.Description); + Assert.False(vm.IsPersisted(draft)); + } + + [Fact] + public async Task VorlageAusVorgangstab_OeffnetEntwurfUndVerknuepftNachSpeichern() + { + AddEntry(0, AttendanceStatus.Unexcused); + var vorgaenge = new FakeVorgangRepository(); + var vorgang = new Vorgang { Title = "Fehlzeiten", StudentIds = [_student.Id], Tags = ["Absentismus"] }; + vorgaenge.Add(vorgang); + var documentation = new FakeDocumentation(); + var vm = new StudentVorgaengeViewModel(vorgaenge, documentation, new FakeStudents([_student]), BuildService()); + vm.Load(_student.Id); + var wasDraft = false; + vm.OnEditDocumentation = (_, editing) => + { + wasDraft = vm.IsNewDocumentationDraft(editing!); + return Task.FromResult(editing); + }; + + var item = Assert.Single(vm.Cases); + Assert.True(item.HasDocumentationTemplates); + await item.CreateFromTemplateCommand.ExecuteAsync(VorgangDocumentationTemplates.Conversation); + + Assert.True(wasDraft); + var saved = Assert.Single(documentation.GetByStudent(_student.Id)); + Assert.Equal(DocumentationType.Conversation, saved.Type); + Assert.Contains("Fehlquote 100 %", saved.Content); + Assert.Contains(saved.Id, vorgang.DocumentationIds); + Assert.True(Assert.Single(vm.Cases).IsRevealed); + } +} diff --git a/LehrerApp.Desktop.Tests/VorgangBausteineTests.cs b/LehrerApp.Desktop.Tests/VorgangBausteineTests.cs new file mode 100644 index 0000000..780442c --- /dev/null +++ b/LehrerApp.Desktop.Tests/VorgangBausteineTests.cs @@ -0,0 +1,116 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Desktop.Views.Shared; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für Standardformulierungen, schülerabhängige Bausteine im Vorgang-Dialog, den WebUntis-Stand +/// der Klassenlehrer-Vorgänge und die Wochentags-Ausrichtung des kompakten Monatskalenders. +public sealed class VorgangBausteineTests +{ + [Fact] + public void KompakterKalender_BeginntInDerSpalteDesWochentags() + { + // 1. September 2026 ist ein Dienstag -> eine Leerzelle (Montag) davor. + var days = Enumerable.Range(1, 30).Select(d => new ClassTeacherCompactCalendarDay( + new DateOnly(2026, 9, d), "", "#00000000", "", false)).ToList(); + + var cells = CompactMonthCalendar.BuildCells(days); + + Assert.True(cells[0].IsPlaceholder); + Assert.Equal(new DateOnly(2026, 9, 1), cells[1].Day!.Date); + Assert.Equal(31, cells.Count); + } + + [Fact] + public void KompakterKalender_MonatsbeginnAmMontag_OhneLeerzelle() + { + var days = new[] { new ClassTeacherCompactCalendarDay(new DateOnly(2026, 6, 1), "", "", "", false) }; + Assert.False(CompactMonthCalendar.BuildCells(days)[0].IsPlaceholder); + } + + [Fact] + public void Standardformulierung_WirdAlsZeileAngehaengt() + { + var vm = new VorgangDialogViewModel([new StudentOption(Guid.NewGuid(), "Anna")], null, + phraseBlocks: VorgangPhraseBlocks.ForStudent(new DateOnly(2026, 9, 26))) { Description = "Notiz" }; + + vm.InsertPhraseCommand.Execute(vm.PhraseBlocks.First(p => p.Label == "Klassenleitung informiert")); + + Assert.Equal("Notiz\n26.09.2026: Klassenleitung informiert.", vm.Description); + } + + [Fact] + public void SchuelerBausteine_FolgenDerAuswahlImDialog() + { + var anna = new StudentOption(Guid.NewGuid(), "Anna"); + var ben = new StudentOption(Guid.NewGuid(), "Ben"); + var vm = new VorgangDialogViewModel([anna, ben], null, + studentQuickBlocks: ids => ids.Select(id => new VorgangQuickBlock(id == anna.Id ? "A" : "B", "")).ToList()); + + Assert.False(vm.HasQuickBlocks); + vm.StudentOptions[1].IsSelected = true; + Assert.Equal(["B"], vm.QuickBlocks.Select(b => b.Label)); + vm.StudentOptions[0].IsSelected = true; + Assert.Equal(["A", "B"], vm.QuickBlocks.Select(b => b.Label)); + } + + private static ClassAbsenceDaySummaryRow Absence(DateOnly date, string name, int periods, bool unexcused, + bool fullDay = false, string reason = "Krankheit") => new(date, name, null, periods, periods * 45, + ["M"], [1], [unexcused ? "nicht entschuldigt" : "entschuldigt"], [reason], null, null, fullDay); + + [Fact] + public void KlassenlehrerStand_ZaehltFehlzeitenVerspaetungenUndKlassenbuch() + { + var student = new StudentOption(Guid.NewGuid(), "Beispiel Anna"); + var today = new DateOnly(2026, 9, 26); + var absences = new[] + { + Absence(today.AddDays(-3), "Anna Beispiel", 6, unexcused: true, fullDay: true), + Absence(today.AddDays(-5), "Anna Beispiel", 2, unexcused: false), + Absence(today.AddDays(-6), "Anna Beispiel", 0, unexcused: false, reason: "Verspätung"), + Absence(today.AddDays(-3), "Ben Fremd", 6, unexcused: true), + }; + var register = new[] + { + new ClassTeacherClassRegisterRow(today.AddDays(-2), "D", "Anna Beispiel", "MUE", "Hausaufgaben fehlen", "Negativ", null), + new ClassTeacherClassRegisterRow(today.AddDays(-1), "E", "Anna Beispiel", "SCH", "Lob", "Positiv", null), + }; + + var facts = ClassTeacherStudentFacts.Build(student, "Anna Beispiel", "2026/27", today, absences, register); + + Assert.Equal(8, facts.AbsentPeriods); + Assert.Equal(1, facts.UnexcusedDays); + Assert.Single(facts.LateDays); + Assert.Equal(1, facts.NegativeRegisterCount); + Assert.Single(facts.HomeworkEntries); + Assert.Contains("8 Fehlstunden an 2 Tagen", facts.AbsenceShort); + + var labels = facts.QuickBlocks(withName: true).Select(b => b.Label).ToList(); + Assert.Contains("Fehlzeiten · Beispiel Anna", labels); + + var ctx = facts.TemplateContext(new Vorgang { Title = "Fehlzeiten", Tags = ["Absentismus"] }); + Assert.Equal([facts.AbsenceShort], ctx.SituationLines); + var letter = VorgangDocumentationTemplates.Build(VorgangDocumentationTemplates.ParentLetter, + new Vorgang { Title = "Fehlzeiten", Tags = ["Absentismus"] }, ctx)!; + Assert.StartsWith("Sehr geehrte Erziehungsberechtigte von Anna Beispiel", letter.ParentLetterData!.DraftContent); + } + + [Fact] + public void Vorlage_KlassenleitungInformieren() + { + var id = Guid.NewGuid(); + var ctx = new VorgangTemplateContext(id, "Anna Beispiel", new DateOnly(2026, 9, 26), ["Fehlquote 25 %"], + new Dictionary()); + + var doc = VorgangDocumentationTemplates.Build(VorgangDocumentationTemplates.InformClassTeacher, + new Vorgang { Title = "Fehlzeiten" }, ctx)!; + + Assert.Equal(DocumentationType.Conversation, doc.Type); + Assert.Equal(["Klassenleitung"], doc.Participants); + Assert.Contains("Klassenleitung am 26.09.2026 informiert über:\n- Fehlquote 25 %", doc.Content); + Assert.Equal(id, doc.StudentId); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 69f43e6..59c1582 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -358,6 +358,7 @@ public static class AppBootstrapper // Transient: neue Instanz pro Navigation (für Detailseiten) services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/LehrerApp.Desktop/Services/StudentWarningEvidenceService.cs b/LehrerApp.Desktop/Services/StudentWarningEvidenceService.cs new file mode 100644 index 0000000..48ed5e7 --- /dev/null +++ b/LehrerApp.Desktop/Services/StudentWarningEvidenceService.cs @@ -0,0 +1,122 @@ +using System.Globalization; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; + +namespace LehrerApp.Desktop.Services; + +public enum StudentEvidenceKind { Absence, Lateness, Homework } + +/// Ein einzelner Beleg hinter einer klassenübergreifenden Warnung (ein Fehltermin, eine +/// Verspätung, eine offene Hausaufgabe) — Code/Farbe bewusst identisch mit der Klassenlehrer- +/// Monatsansicht (ClassTeacherDetailsViewModel.BuildDayEvents: U/E/A/V/H), damit dasselbe Kürzel in +/// beiden Widgets dasselbe bedeutet. +public sealed record StudentEvidenceEvent(DateOnly Date, string GroupName, string Label, string Code, + string ColorHex, StudentEvidenceKind Kind) +{ + private static readonly CultureInfo De = new("de-DE"); + public string DateLabel => Date.ToString("ddd dd.MM.", De); + public string ListLine => $"{Date:dd.MM.yyyy} · {GroupName}: {Label}"; +} + +/// Momentaufnahme aller Belege einer/eines Schüler*in im laufenden Schuljahr. Grundlage +/// sowohl für das Detail-Popup der Dashboard-Warnungen als auch für die Schnellbausteine/Vorlagen +/// beim Anlegen von Vorgängen und Dokumentation — beides soll exakt dieselben Zahlen zeigen, die +/// die Warnung ausgelöst haben. +public sealed record StudentWarningEvidence( + Guid StudentId, string StudentName, string StudentFirstName, string StudentNaturalName, string SchoolYear, DateOnly From, DateOnly To, DateOnly AsOf, + AttendanceBalance Balance, + IReadOnlyList Absences, + IReadOnlyList Lateness, + IReadOnlyList MissingHomework) +{ + public int SignificantlyLateCount => Lateness.Count(e => e.Code == "V!"); +} + +/// +/// Sammelt die Belege hinter den drei klassenübergreifenden Dashboard-Warnungen (Fehlzeiten, +/// Hausaufgaben-Rückstand, Verspätungen) für eine/n einzelne/n Schüler*in. Zählt bewusst nach +/// denselben Regeln wie DashboardViewModel.LoadAttendanceWarnings/LoadMissingHomeworkWarnings/ +/// LoadLatenessWarnings (Schuljahreszeitraum, AttendanceBalanceService, HomeworkDisplay.CountsAsMissing, +/// Late/SignificantlyLate) — eine abweichende Zahl im Popup als in der Warnzeile wäre verwirrend. +/// Keine eigene Persistenz, rein lesend. +/// +public class StudentWarningEvidenceService +{ + private readonly IStudentRepository _students; + private readonly IParticipationSessionRepository _sessions; + private readonly IParticipationRepository _entries; + private readonly IGroupRepository _groups; + private readonly SchoolYearService _sy; + private readonly AttendanceBalanceService _attendanceBalance; + private readonly Func _now; + + public StudentWarningEvidenceService(IStudentRepository students, IParticipationSessionRepository sessions, + IParticipationRepository entries, IGroupRepository groups, SchoolYearService sy, + AttendanceBalanceService attendanceBalance, Func? now = null) + { + _students = students; + _sessions = sessions; + _entries = entries; + _groups = groups; + _sy = sy; + _attendanceBalance = attendanceBalance; + _now = now ?? (() => DateTime.Now); + } + + public StudentWarningEvidence Build(Guid studentId) + { + var schoolYear = _sy.CurrentSchoolYear(); + var from = _sy.SchoolYearStart(schoolYear); + var to = _sy.SchoolYearEnd(schoolYear); + var sessions = _sessions.GetAll().ToDictionary(s => s.Id); + var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name); + + var dated = _entries.GetByStudent(studentId) + .Select(e => (Entry: e, Session: sessions.GetValueOrDefault(e.SessionId))) + .Where(x => x.Session is not null && x.Session.Date >= from && x.Session.Date <= to) + .Select(x => (x.Entry, x.Session!.Date, Group: groupNames.GetValueOrDefault(x.Session.GroupId, ""))) + .OrderByDescending(x => x.Date).ThenBy(x => x.Group) + .ToList(); + + var balance = _attendanceBalance.Calculate(dated.Select(x => (x.Date, x.Entry.Attendance)), from, to); + + var absences = new List(); + var lateness = new List(); + var homework = new List(); + foreach (var (entry, date, group) in dated) + { + if (AbsenceEvent(entry.Attendance, date, group) is { } absence) absences.Add(absence); + if (entry.Attendance is AttendanceStatus.Late or AttendanceStatus.SignificantlyLate) + lateness.Add(new StudentEvidenceEvent(date, group, AttendanceDisplay.Label(entry.Attendance), + entry.Attendance == AttendanceStatus.SignificantlyLate ? "V!" : "V", "#EF6C00", + StudentEvidenceKind.Lateness)); + var hw = HomeworkDisplay.Effective(entry); + if (HomeworkDisplay.CountsAsMissing(hw)) + homework.Add(new StudentEvidenceEvent(date, group, HomeworkDisplay.Label(hw), "H", "#6A1B9A", + StudentEvidenceKind.Homework)); + } + + var student = _students.GetById(studentId); + return new StudentWarningEvidence(studentId, student?.FullName ?? "", student?.FirstName ?? "", + student is null ? "" : $"{student.FirstName} {student.LastName}".Trim(), schoolYear, from, to, DateOnly.FromDateTime(_now()), + balance, absences, lateness, homework); + } + + /// Nur die Status, die AttendanceBalanceService in die Fehlquote einrechnet (entschuldigt, + /// unentschuldigt, Entschuldigung offen) — schulisch veranlasste Abwesenheit zählt dort nicht als + /// Fehlzeit und taucht deshalb auch hier nicht als Beleg auf. + private static StudentEvidenceEvent? AbsenceEvent(AttendanceStatus? status, DateOnly date, string group) + { + var (code, color) = status switch + { + AttendanceStatus.Unexcused or AttendanceStatus.Truant or AttendanceStatus.LeftDuringClass => ("U", "#C62828"), + AttendanceStatus.ExcusePending => ("A", "#1565C0"), + AttendanceStatus.Excused => ("E", "#2E7D32"), + _ => ("", ""), + }; + return code == "" ? null + : new StudentEvidenceEvent(date, group, AttendanceDisplay.Label(status), code, color, StudentEvidenceKind.Absence); + } +} diff --git a/LehrerApp.Desktop/ViewModels/AttentionItem.cs b/LehrerApp.Desktop/ViewModels/AttentionItem.cs index 164ca18..826ef5e 100644 --- a/LehrerApp.Desktop/ViewModels/AttentionItem.cs +++ b/LehrerApp.Desktop/ViewModels/AttentionItem.cs @@ -59,12 +59,20 @@ public sealed class AttentionItem public bool IsDefused { get; } public IReadOnlyList Actions { get; } public Action? Navigate { get; } + /// Nur bei Attendance/MissingHomework/Lateness gesetzt: Inhalt des "Details"-Popups + /// (Nutzer-Feedback: im Dashboard-Eintrag mehr erfahren). Erst beim ersten Zugriff erzeugt — die + /// Flyout-Bindung greift darauf erst beim Öffnen zu, damit das Dashboard nicht für jede Warnzeile + /// vorab alle Belege sammelt. + public StudentWarningDetailViewModel? Detail => _detail ??= _detailFactory?.Invoke(); + private readonly Func? _detailFactory; + private StudentWarningDetailViewModel? _detail; public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "", string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null, int progressPercent = 0, string progressLabel = "", bool hasProgress = false, bool isWarningTrailing = false, bool isDefused = false, - IReadOnlyList? actions = null, Action? navigate = null) + IReadOnlyList? actions = null, Action? navigate = null, + Func? detailFactory = null) { Kind = kind; Title = title; @@ -80,12 +88,15 @@ public sealed class AttentionItem IsDefused = isDefused; Actions = actions ?? []; Navigate = navigate; + _detailFactory = detailFactory; } public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle); public bool HasTrailingText => !string.IsNullOrWhiteSpace(TrailingText); public bool HasDate => !string.IsNullOrWhiteSpace(DateDisplay); public bool HasActions => Actions.Count > 0; + public bool HasDetail => _detailFactory is not null; + public bool HasFooter => HasActions || HasDetail; public bool HasSeverity => Severity is not null; public bool IsHighSeverity => Severity == AlertSeverity.High; public bool IsMediumSeverity => Severity == AlertSeverity.Medium; diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs new file mode 100644 index 0000000..6bbb4e4 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs @@ -0,0 +1,105 @@ +using System.Text; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Students; + +namespace LehrerApp.Desktop.ViewModels.ClassTeacher; + +/// +/// WebUntis-Stand einer/eines Schüler*in der eigenen Klasse für Schnellbausteine und Vorlagen im +/// Klassenlehrer-"Vorgänge"-Tab — Gegenstück zu StudentWarningEvidence im Schülerdetail. Dort zählen +/// nur die lokal erfassten Mitarbeits-Einträge der eigenen Stunden; die Klassenleitung braucht +/// dagegen die Fehlzeiten über alle Fächer und die Klassenbucheinträge der Kolleg*innen, die +/// WebUntis ohnehin liefert (und für das laufende +/// Schuljahr bereits gecacht hat). Rein aus schon geladenen Zeilen berechnet, testbar ohne Untis. +/// +/// "Vorname Nachname" aus dem lokalen Schülerdatensatz — der Untis-Anzeigename +/// hat je nach Schule eine andere Reihenfolge und taugt nicht für eine Briefanrede. +public sealed record ClassTeacherStudentFacts(StudentOption Student, string NaturalName, string SchoolYear, DateOnly AsOf, + IReadOnlyList AbsenceDays, IReadOnlyList LateDays, + IReadOnlyList RegisterEntries) +{ + public const int MaxListed = 15; + + public int AbsentPeriods => AbsenceDays.Sum(d => d.TotalAbsentPeriods); + public int FullDays => AbsenceDays.Count(d => d.CountsAsFullDay); + public int UnexcusedDays => AbsenceDays.Count(d => d.IsUnexcused); + public int NegativeRegisterCount => RegisterEntries.Count(r => r.IsDangerStatus); + public IReadOnlyList HomeworkEntries => RegisterEntries + .Where(r => ($"{r.CategoryName} {r.Text}").Contains("Hausauf", StringComparison.OrdinalIgnoreCase)).ToList(); + + private string Stand => $"WebUntis-Stand {AsOf:dd.MM.yyyy}"; + + public string AbsenceShort => + $"{AbsentPeriods} Fehlstunde{(AbsentPeriods == 1 ? "" : "n")} an {AbsenceDays.Count} Tag{(AbsenceDays.Count == 1 ? "" : "en")} " + + $"im Schuljahr {SchoolYear} (davon {UnexcusedDays} Tag{(UnexcusedDays == 1 ? "" : "e")} unentschuldigt, " + + $"{FullDays} ganze Fehltag{(FullDays == 1 ? "" : "e")})"; + public string LatenessShort => $"{LateDays.Count} Verspätung{(LateDays.Count == 1 ? "" : "en")} im Schuljahr {SchoolYear}"; + public string RegisterShort => + $"{RegisterEntries.Count} Klassenbucheintr{(RegisterEntries.Count == 1 ? "ag" : "äge")} im Schuljahr {SchoolYear}, davon {NegativeRegisterCount} negativ"; + public string HomeworkShort => $"{HomeworkEntries.Count} Klassenbucheintr{(HomeworkEntries.Count == 1 ? "ag" : "äge")} zu Hausaufgaben im Schuljahr {SchoolYear}"; + + public string AbsenceBlock => $"{Stand}: {AbsenceShort}.\n" + List("Fehltage", AbsenceDays.Select(d => + $"{d.DateLabel}: {d.KindLabel} · {d.FriendlyStatusLabel}" + + (string.IsNullOrWhiteSpace(d.DetailLabel) ? "" : $" · {d.DetailLabel}")).ToList()); + public string LatenessBlock => $"{Stand}: {LatenessShort}.\n" + List("Termine", LateDays.Select(d => + $"{d.DateLabel}: {d.KindLabel}").ToList()); + public string RegisterBlock => $"{Stand}: {RegisterShort}.\n" + List("Einträge", RegisterEntries.Select(RegisterLine).ToList()); + public string HomeworkBlock => $"{Stand}: {HomeworkShort}.\n" + List("Einträge", HomeworkEntries.Select(RegisterLine).ToList()); + + private static string RegisterLine(ClassTeacherClassRegisterRow r) => + $"{r.DateLabel}" + (string.IsNullOrWhiteSpace(r.Subject) ? "" : $" · {r.Subject}") + ": " + + string.Join(" – ", new[] { r.CategoryName, r.Text }.Where(v => !string.IsNullOrWhiteSpace(v))); + + private static string List(string header, IReadOnlyList lines) + { + var sb = new StringBuilder($"{header}:"); + foreach (var line in lines.Take(MaxListed)) sb.Append("\n- ").Append(line); + if (lines.Count > MaxListed) sb.Append($"\n- … und {lines.Count - MaxListed} weitere"); + return sb.ToString(); + } + + /// Nur Bausteine mit Inhalt. Mit (mehrere Beteiligte ausgewählt) + /// tragen Label und Text den Namen, sonst wäre unklar, wessen Zahlen gerade eingefügt werden. + public IEnumerable QuickBlocks(bool withName) + { + string L(string label) => withName ? $"{label} · {Student.Name}" : label; + string T(string text) => withName ? $"{Student.Name} – {text}" : text; + if (AbsenceDays.Count > 0) yield return new(L("Fehlzeiten"), T(AbsenceBlock)); + if (LateDays.Count > 0) yield return new(L("Verspätungen"), T(LatenessBlock)); + if (HomeworkEntries.Count > 0) yield return new(L("Hausaufgaben (Klassenbuch)"), T(HomeworkBlock)); + if (RegisterEntries.Count > 0) yield return new(L("Klassenbucheinträge"), T(RegisterBlock)); + } + + /// Anlass-Zeilen passend zu den Vorgangs-Tags; ohne passendes Tag (z.B. "Konflikte") dienen die + /// negativen Klassenbucheinträge als Anlass, sofern vorhanden. + public VorgangTemplateContext TemplateContext(Vorgang vorgang) + { + var lines = new List(); + foreach (var tag in vorgang.Tags.Select(t => t.ToLowerInvariant()).Distinct()) + { + if (tag == "absentismus" && AbsenceDays.Count > 0) lines.Add(AbsenceShort); + if (tag == "verspätungen" && LateDays.Count > 0) lines.Add(LatenessShort); + if (tag == "hausaufgaben" && HomeworkEntries.Count > 0) lines.Add(HomeworkShort); + } + if (lines.Count == 0 && NegativeRegisterCount > 0) lines.Add(RegisterShort); + + return new VorgangTemplateContext(Student.Id, NaturalName, AsOf, lines, + new Dictionary + { + [VorgangDocumentationTemplates.UntisAbsenceStatus] = new($"Fehlzeiten-Stand (WebUntis) {AsOf:dd.MM.yyyy}", AbsenceBlock), + [VorgangDocumentationTemplates.LatenessStatus] = new($"Verspätungs-Stand (WebUntis) {AsOf:dd.MM.yyyy}", LatenessBlock), + [VorgangDocumentationTemplates.ClassRegisterStatus] = new($"Klassenbuch-Stand {AsOf:dd.MM.yyyy}", RegisterBlock), + }); + } + + public static ClassTeacherStudentFacts Build(StudentOption student, string naturalName, string schoolYear, DateOnly asOf, + IReadOnlyList classAbsenceDays, IReadOnlyList classRegister) + { + var own = classAbsenceDays.Where(a => UntisNameMatching.NamesMatch(a.StudentName, student.Name)) + .OrderByDescending(a => a.Date).ToList(); + var register = classRegister.Where(r => UntisNameMatching.NamesMatch(r.StudentName, student.Name)) + .OrderByDescending(r => r.Date).ToList(); + return new ClassTeacherStudentFacts(student, naturalName, schoolYear, asOf, + own.Where(a => !a.IsLate).ToList(), own.Where(a => a.IsLate).ToList(), register); + } +} diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs index 65c9288..052e38a 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCasesViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Students; @@ -26,6 +27,10 @@ public partial class ClassTeacherCasesViewModel : ObservableObject private string _className = ""; private List _rosterStudentOptions = []; + // WebUntis-Stand je Schüler*in für Schnellbausteine/Vorlagen (ClassTeacherStudentFacts) — erst + // beim Öffnen eines Dialogs geladen (EnsureFactsAsync), nicht bei jedem Neuladen der Liste. + private Dictionary _facts = []; + private Guid? _documentationDraftId; public ObservableCollection Cases { get; } = []; @@ -98,7 +103,10 @@ public partial class ClassTeacherCasesViewModel : ObservableObject .Where(n => n != ""); var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation, UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation) - { IsRevealed = revealedIds.Contains(vorgang.Id) }; + { + IsRevealed = revealedIds.Contains(vorgang.Id), + OnCreateFromTemplate = CreateDocumentationFromTemplate, + }; var linkedIds = vorgang.DocumentationIds.ToHashSet(); foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id))) @@ -117,10 +125,83 @@ public partial class ClassTeacherCasesViewModel : ObservableObject private void NotifyState() => OnPropertyChanged(nameof(HasCases)); + /// Lädt Fehlzeiten und Klassenbucheinträge des laufenden Schuljahres (aus dem Untis-Cache, der + /// kalte Bereich ist dort dauerhaft gespeichert) und leitet daraus den Stand je Schüler*in ab. + /// Scheitert der Abruf (offline, Sitzung abgelaufen), gibt es eben nur die Standardformulierungen + /// statt eines Fehlers — der Dialog selbst soll davon nicht abhängen. + private async Task EnsureFactsAsync() + { + try + { + var sy = new SchoolYearService(); + var schoolYear = sy.CurrentSchoolYear(); + var today = DateOnly.FromDateTime(DateTime.Today); + var start = sy.SchoolYearStart(schoolYear); + var absencesTask = _cache.GetAbsencesAsync(_className, start, today); + var registerTask = _cache.GetClassRegisterEventsAsync(_className, start, today); + await Task.WhenAll(absencesTask, registerTask); + + var absenceDays = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); + var register = registerTask.Result + .Select(e => (Entry: e, Valid: DateOnly.TryParseExact(e.Date.ToString(), "yyyyMMdd", out var d), Date: d)) + .Where(x => x.Valid) + .Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName, + x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text)) + .ToList(); + _facts = _rosterStudentOptions.ToDictionary(o => o.Id, o => ClassTeacherStudentFacts.Build(o, + NaturalName(o), schoolYear, today, absenceDays, register)); + } + catch (Exception) + { + // Bewusst still: ohne WebUntis fehlen nur die Zahlen-Bausteine, Standardformulierungen + // und Vorlagen (dann ohne Zahlen) bleiben nutzbar. + _facts = []; + } + } + + private string NaturalName(StudentOption option) => + _students.GetById(option.Id) is { } s ? $"{s.FirstName} {s.LastName}".Trim() : option.Name; + + /// Schnellbausteine für die aktuell im Dialog ausgewählten Schüler*innen — bei mehreren mit Namen. + public IReadOnlyList QuickBlocksFor(IReadOnlyList studentIds) => studentIds + .Where(_facts.ContainsKey) + .SelectMany(id => _facts[id].QuickBlocks(withName: studentIds.Count > 1)) + .ToList(); + + public bool IsNewDocumentationDraft(Documentation doc) => doc.Id == _documentationDraftId; + + /// Vorlagen beziehen sich auf die/den erste/n Beteiligte/n des Vorgangs (Dokumentation gehört immer + /// zu genau einer Person) — der Dialog bietet deshalb auch nur diese Person an, sonst passten die + /// eingesetzten Zahlen nicht mehr zur gewählten Person. + private async Task CreateDocumentationFromTemplate(VorgangItem item, string key) + { + if (OnEditDocumentation is null) return; + var option = _rosterStudentOptions.FirstOrDefault(o => item.Model.StudentIds.Contains(o.Id)); + if (option is null) return; + await EnsureFactsAsync(); + var context = _facts.TryGetValue(option.Id, out var facts) + ? facts.TemplateContext(item.Model) + : new VorgangTemplateContext(option.Id, NaturalName(option), DateOnly.FromDateTime(DateTime.Today), [], + new Dictionary()); + var draft = VorgangDocumentationTemplates.Build(key, item.Model, context); + if (draft is null) return; + + _documentationDraftId = draft.Id; + Documentation? result; + try { result = await OnEditDocumentation(option.Id, [option], draft); } + finally { _documentationDraftId = null; } + if (result is null) return; + _documentation.Save(result); + item.Model.DocumentationIds.Add(result.Id); + _vorgaenge.Save(item.Model); + await LoadInternal(); + } + [RelayCommand] private async Task AddVorgang() { if (OnEditVorgang is null) return; + await EnsureFactsAsync(); var result = await OnEditVorgang(_rosterStudentOptions, null); if (result is null) return; _vorgaenge.Save(result); @@ -131,6 +212,7 @@ public partial class ClassTeacherCasesViewModel : ObservableObject private async Task EditVorgang(VorgangItem? item) { if (item is null || OnEditVorgang is null) return; + await EnsureFactsAsync(); var result = await OnEditVorgang(_rosterStudentOptions, item.Model); if (result is null) return; _vorgaenge.Save(result); diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs index 8c12927..d5d1619 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/VorgangViewModels.cs @@ -94,6 +94,19 @@ public partial class VorgangItem : ObservableObject [RelayCommand] private void Reveal() => IsRevealed = !IsRevealed; + /// Nutzer-Feedback: standardisierte Dokumentationseinträge statt alles von Hand — nur im + /// schülerbezogenen Vorgänge-Tab gesetzt (StudentVorgaengeViewModel), der Parameter ist ein + /// Schlüssel aus VorgangDocumentationTemplates. Eigener Command je Item statt $parent-Bindung, + /// weil die Auswahl in einem MenuFlyout-Popup liegt, das nicht im normalen visuellen Baum hängt. + public Func? OnCreateFromTemplate { get; set; } + public bool HasDocumentationTemplates => OnCreateFromTemplate is not null; + + [RelayCommand] + private async Task CreateFromTemplate(string? key) + { + if (key is not null && OnCreateFromTemplate is not null) await OnCreateFromTemplate(this, key); + } + /// Öffnet den vollen Dokumentations-Dialog zum Nachlesen/Bearbeiten, ohne den Vorgänge-Tab zu /// verlassen (Nutzer-Feedback: bisher musste man dafür in den Schülerdatensatz wechseln). [RelayCommand] @@ -138,12 +151,35 @@ public partial class VorgangDialogViewModel : ObservableObject [ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _studentsError = ""; - public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten"; + private readonly bool _isNewDraft; + public string DialogTitle => _editing is null || _isNewDraft ? "Vorgang anlegen" : "Vorgang bearbeiten"; public Vorgang? Result { get; private set; } - public VorgangDialogViewModel(List rosterStudents, Vorgang? editing) + /// Per Klick in die Beschreibung einfügbare Textbausteine mit dem aktuellen Stand (Fehlquote, + /// Fehltermine, WebUntis-Fehlzeiten, …). Setzt sich aus festen Bausteinen (Schülerdetail) und — + /// im Klassenlehrer-Tab mit Mehrfachauswahl — aus Bausteinen je ausgewählter/m Schüler*in + /// zusammen, die bei jeder Änderung der Auswahl neu berechnet werden. + public ObservableCollection QuickBlocks { get; } = []; + public bool HasQuickBlocks => QuickBlocks.Count > 0; + /// Datumsgestempelte Standardformulierungen ("Klassenleitung informiert", …), siehe VorgangPhraseBlocks. + public IReadOnlyList PhraseBlocks { get; } + public bool HasPhraseBlocks => PhraseBlocks.Count > 0; + + private readonly IReadOnlyList _fixedQuickBlocks; + private readonly Func, IReadOnlyList>? _studentQuickBlocks; + + /// editing ist ein noch nicht gespeicherter, vorbefüllter Entwurf (z.B. + /// aus einer Dashboard-Warnung) — Titel "anlegen" statt "bearbeiten". + public VorgangDialogViewModel(List rosterStudents, Vorgang? editing, + IReadOnlyList? quickBlocks = null, bool isNewDraft = false, + IReadOnlyList? phraseBlocks = null, + Func, IReadOnlyList>? studentQuickBlocks = null) { _editing = editing; + _isNewDraft = isNewDraft; + _fixedQuickBlocks = quickBlocks ?? []; + _studentQuickBlocks = studentQuickBlocks; + PhraseBlocks = phraseBlocks ?? []; // Bei genau einem Kandidaten (z.B. der schülerbezogene Vorgänge-Tab im Schülerdetail) und // einem neuen Vorgang ist ohnehin nur eine Auswahl möglich — Nutzer-Feedback: der sonst // nötige Extra-Klick auf die einzige Checkbox ist unnötige Reibung. @@ -153,6 +189,13 @@ public partial class VorgangDialogViewModel : ObservableObject IsSelected = editing?.StudentIds.Contains(s.Id) == true || (editing is null && rosterStudents.Count == 1), })); + if (_studentQuickBlocks is not null) + foreach (var option in StudentOptions) + option.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(VorgangStudentOption.IsSelected)) RebuildQuickBlocks(); + }; + RebuildQuickBlocks(); if (editing is null) return; Title = editing.Title; @@ -171,6 +214,36 @@ public partial class VorgangDialogViewModel : ObservableObject [RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); } + /// Hängt den Baustein als eigenen Absatz an — nie überschreiben, damit bereits Getipptes bleibt. + [RelayCommand] + private void InsertQuickBlock(VorgangQuickBlock? block) + { + if (block is null) return; + var current = (Description ?? "").TrimEnd(); + Description = current.Length == 0 ? block.Text : current + "\n\n" + block.Text; + } + + /// Standardformulierungen sind Verlaufsnotizen — eine Zeile, kein eigener Absatz. + [RelayCommand] + private void InsertPhrase(VorgangQuickBlock? block) + { + if (block is null) return; + var current = (Description ?? "").TrimEnd(); + Description = current.Length == 0 ? block.Text : current + "\n" + block.Text; + } + + private void RebuildQuickBlocks() + { + QuickBlocks.Clear(); + foreach (var block in _fixedQuickBlocks) QuickBlocks.Add(block); + if (_studentQuickBlocks is not null) + { + var selected = StudentOptions.Where(o => o.IsSelected).Select(o => o.StudentId).ToList(); + foreach (var block in _studentQuickBlocks(selected)) QuickBlocks.Add(block); + } + OnPropertyChanged(nameof(HasQuickBlocks)); + } + [RelayCommand] private void Save() { diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index 4285ef1..6e1efff 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -43,6 +43,7 @@ public partial class DashboardViewModel : ObservableObject private readonly SchoolWeatherService? _schoolWeather; private readonly UntisHubService _untisHub; private readonly WebUntisIntegrationService _webUntis; + private readonly StudentWarningEvidenceService _evidence; private readonly Func _now; private const int OpenExcuseMaxAgeDays = 21; @@ -240,6 +241,11 @@ public partial class DashboardViewModel : ObservableObject // waeren Tests fuer "kurz vor/nach Ablauf einer Wartezeit" je nach Ausfuehrungsuhrzeit // zufaellig rot oder gruen (siehe TODO.md, Abschnitt 9, Nachtrag Uhrzeit-Wraparound). _now = now ?? (() => DateTime.Now); + // Bewusst hier aus den ohnehin injizierten Repositories gebaut statt als weiterer + // Konstruktor-Parameter — dieselben Datenquellen wie die Warnungen selbst, und die lange + // Parameterliste (inkl. aller Test-Aufrufer) bleibt unverändert. + _evidence = new StudentWarningEvidenceService(students, participationSessions, participationEntries, + groups, sy, attendanceBalance, _now); if (annualPlanSync is not null) { annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); @@ -518,7 +524,8 @@ public partial class DashboardViewModel : ObservableObject AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %", isWarningTrailing: true, isDefused: isDefused, actions: [SnoozeAction(w.StudentId, AbsentismusTag, $"Fehlzeiten – {w.StudentName}")], - navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, AbsentismusTag, "Fehlzeiten")); + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, AbsentismusTag, "Fehlzeiten"), + detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.Attendance, _evidence.Build(w.StudentId))); // ── Hausaufgaben-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ── @@ -552,7 +559,8 @@ public partial class DashboardViewModel : ObservableObject AttentionKind.MissingHomework, w.StudentName, trailingText: $"{w.MissingCount} offen", isWarningTrailing: true, isDefused: isDefused, actions: [SnoozeAction(w.StudentId, HausaufgabenTag, $"Hausaufgaben – {w.StudentName}")], - navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, HausaufgabenTag, "Hausaufgaben")); + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, HausaufgabenTag, "Hausaufgaben"), + detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.MissingHomework, _evidence.Build(w.StudentId))); // ── Verspätungs-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ── // @@ -590,7 +598,8 @@ public partial class DashboardViewModel : ObservableObject AttentionKind.Lateness, w.StudentName, trailingText: $"{w.LateCount}× verspätet", isWarningTrailing: true, isDefused: isDefused, actions: [SnoozeAction(w.StudentId, VerspaetungenTag, $"Verspätungen – {w.StudentName}")], - navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, VerspaetungenTag, "Verspätungen")); + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, VerspaetungenTag, "Verspätungen"), + detailFactory: () => new StudentWarningDetailViewModel(AttentionKind.Lateness, _evidence.Build(w.StudentId))); // ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── // diff --git a/LehrerApp.Desktop/ViewModels/StudentWarningDetailViewModel.cs b/LehrerApp.Desktop/ViewModels/StudentWarningDetailViewModel.cs new file mode 100644 index 0000000..20af828 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/StudentWarningDetailViewModel.cs @@ -0,0 +1,158 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.ClassTeacher; + +namespace LehrerApp.Desktop.ViewModels; + +public sealed record WarningDetailFigure(string Label, string Value); + +/// +/// Inhalt des "Details"-Popups einer klassenübergreifenden Dashboard-Warnung (Nutzer-Feedback: im +/// Eintrag mehr erfahren, ohne erst ins Schülerdetail zu springen). Recycelt das Monats-Widget der +/// Klassenlehrer-Übersicht ("Monat auf einen Blick", + +/// Views/Shared/CompactMonthCalendar) — dort aus WebUntis-Daten der eigenen Klasse, hier aus den +/// lokal erfassten Mitarbeits-Einträgen aller Lerngruppen, mit denselben Kürzeln/Farben. +/// Wird erst beim Öffnen des Popups erzeugt (AttentionItem.Detail ist lazy), damit das Dashboard +/// nicht für jede Warnzeile vorab alle Belege zusammensucht. +/// +public partial class StudentWarningDetailViewModel : ObservableObject +{ + private static readonly CultureInfo De = new("de-DE"); + private const int MaxListedEvents = 10; + + private readonly IReadOnlyList _calendarEvents; + private readonly DateOnly _firstMonth; + private readonly DateOnly _lastMonth; + private readonly DateOnly _today; + + public string Header { get; } + public string Summary { get; } + public IReadOnlyList Figures { get; } + public string ListHeader { get; } + public IReadOnlyList Events { get; } + public string MoreEventsHint { get; } + public bool HasMoreEvents => !string.IsNullOrEmpty(MoreEventsHint); + public bool HasEvents => Events.Count > 0; + public string Legend { get; } + + public ObservableCollection CalendarDays { get; } = []; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(MonthLabel))] + [NotifyCanExecuteChangedFor(nameof(PreviousMonthCommand), nameof(NextMonthCommand))] + private DateOnly _month; + public string MonthLabel => Month.ToString("MMMM yyyy", De); + + public StudentWarningDetailViewModel(AttentionKind kind, StudentWarningEvidence e) + { + var b = e.Balance; + Header = e.StudentName; + switch (kind) + { + case AttentionKind.MissingHomework: + Summary = $"{e.MissingHomework.Count} offene Hausaufgaben · Schuljahr {e.SchoolYear}"; + Figures = + [ + new("Nicht gemacht", e.MissingHomework.Count(x => x.Label.StartsWith("Nicht", StringComparison.Ordinal)).ToString()), + new("Teilweise", e.MissingHomework.Count(x => x.Label.StartsWith("Teilweise", StringComparison.Ordinal)).ToString()), + ]; + ListHeader = "Betroffene Termine"; + _calendarEvents = e.MissingHomework; + Events = e.MissingHomework; + Legend = "H Hausaufgabe offen/unvollständig"; + break; + case AttentionKind.Lateness: + Summary = $"{e.Lateness.Count}× verspätet · Schuljahr {e.SchoolYear}"; + Figures = + [ + new("Verspätet", (e.Lateness.Count - e.SignificantlyLateCount).ToString()), + new("Erheblich", e.SignificantlyLateCount.ToString()), + new("Fehlquote", $"{b.AbsenceRatePercent.ToString("0.#", De)} %"), + ]; + ListHeader = "Verspätungen"; + _calendarEvents = [.. e.Absences, .. e.Lateness]; + Events = e.Lateness; + Legend = "V verspätet · U unentsch. · A Entsch. offen · E entsch."; + break; + default: + Summary = $"Fehlquote {b.AbsenceRatePercent.ToString("0.#", De)} % · {b.TotalChecked} Stunden erfasst · Schuljahr {e.SchoolYear}"; + Figures = + [ + new("Entschuldigt", b.Excused.ToString()), + new("Unentschuldigt", b.Unexcused.ToString()), + new("Entsch. offen", b.ExcusePending.ToString()), + new("Verspätet", e.Lateness.Count.ToString()), + ]; + ListHeader = "Fehltermine"; + _calendarEvents = [.. e.Absences, .. e.Lateness]; + Events = e.Absences; + Legend = "U unentsch. · A Entsch. offen · E entsch. · V verspätet"; + break; + } + + if (Events.Count > MaxListedEvents) + { + MoreEventsHint = $"… und {Events.Count - MaxListedEvents} weitere"; + Events = Events.Take(MaxListedEvents).ToList(); + } + else MoreEventsHint = ""; + + _firstMonth = FirstOfMonth(e.From); + _lastMonth = FirstOfMonth(e.AsOf < e.To ? e.AsOf : e.To); + if (_lastMonth < _firstMonth) _lastMonth = _firstMonth; + // Startet beim Monat des jüngsten Belegs — der aktuelle Monat kann zu Monatsbeginn noch + // leer sein, obwohl die Warnung gerade wegen der Vorwochen anschlägt. + var latest = Events.Count > 0 ? FirstOfMonth(Events[0].Date) : _lastMonth; + _today = e.AsOf; + Month = latest < _firstMonth ? _firstMonth : latest > _lastMonth ? _lastMonth : latest; + RebuildCalendar(); + } + + private static DateOnly FirstOfMonth(DateOnly d) => new(d.Year, d.Month, 1); + + partial void OnMonthChanged(DateOnly value) => RebuildCalendar(); + + private bool CanGoPrevious() => Month > _firstMonth; + private bool CanGoNext() => Month < _lastMonth; + + [RelayCommand(CanExecute = nameof(CanGoPrevious))] private void PreviousMonth() => Month = Month.AddMonths(-1); + [RelayCommand(CanExecute = nameof(CanGoNext))] private void NextMonth() => Month = Month.AddMonths(1); + + private void RebuildCalendar() + { + CalendarDays.Clear(); + foreach (var day in BuildMonth(Month, _calendarEvents, _today)) CalendarDays.Add(day); + } + + /// Gleiche Verdichtung wie ClassTeacherOverviewViewModel.BuildCompactMonthDays: eine Zelle je + /// Kalendertag, das stärkste Kürzel ersetzt die Tageszahl, alle Details stehen im Tooltip. + public static IReadOnlyList BuildMonth(DateOnly month, + IReadOnlyList events, DateOnly today) + { + var result = new List(); + var byDate = events.Where(e => e.Date.Year == month.Year && e.Date.Month == month.Month) + .GroupBy(e => e.Date).ToDictionary(g => g.Key, g => g.ToList()); + for (var n = 1; n <= DateTime.DaysInMonth(month.Year, month.Month); n++) + { + var date = new DateOnly(month.Year, month.Month, n); + if (!byDate.TryGetValue(date, out var dayEvents)) + { + result.Add(new ClassTeacherCompactCalendarDay(date, "", "#00000000", + $"{date:dd.MM.yyyy}: unauffällig", date == today)); + continue; + } + var strongest = dayEvents.OrderBy(e => Priority(e.Code)).First(); + var tooltip = $"{date:dd.MM.yyyy}\n" + string.Join("\n", dayEvents.Select(e => $"{e.GroupName}: {e.Label}")); + result.Add(new ClassTeacherCompactCalendarDay(date, strongest.Code.TrimEnd('!'), strongest.ColorHex, + tooltip, date == today)); + } + return result; + } + + private static int Priority(string code) => code switch + { + "U" => 0, "A" => 1, "E" => 2, "V!" => 3, "V" => 4, _ => 5, + }; +} diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs index 051897f..9757cc9 100644 --- a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs @@ -157,16 +157,20 @@ public partial class DocumentationDialogViewModel : ObservableObject public string[] TypeOptions => DocumentationTypeDisplay.Options; public string[] SupportStatusOptions => SupportStatusDisplay.Options; - public string DialogTitle => _editing is null ? "Dokumentation hinzufügen" : "Dokumentation bearbeiten"; + private readonly bool _isNewDraft; + public string DialogTitle => _editing is null || _isNewDraft ? "Dokumentation hinzufügen" : "Dokumentation bearbeiten"; public long MaxAttachmentSizeBytes => IAttachmentStorage.MaxSizeBytes; public Documentation? Result { get; private set; } public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage, - List? studentOptions = null, Guid? contextGroupId = null) + List? studentOptions = null, Guid? contextGroupId = null, bool isNewDraft = false) { _studentId = studentId; _editing = editing; + // editing ist dann ein vorbefüllter, noch ungespeicherter Entwurf (z.B. aus einer + // Vorgangs-Vorlage) — wird wie ein Bestandseintrag vorbelegt, aber als "hinzufügen" betitelt. + _isNewDraft = isNewDraft; _attachmentStorage = attachmentStorage; _contextGroupId = contextGroupId; StudentOptions = studentOptions ?? []; diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs index 1fc2f60..4739e6d 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentVorgaengeViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.ClassTeacher; namespace LehrerApp.Desktop.ViewModels.Students; @@ -21,8 +22,12 @@ public partial class StudentVorgaengeViewModel : ObservableObject private readonly IVorgangRepository _vorgaenge; private readonly IDocumentationRepository _documentation; private readonly IStudentRepository _students; + private readonly StudentWarningEvidenceService? _evidence; private Guid _studentId; + // Id eines aus einer Vorlage erzeugten, noch ungespeicherten Dokumentations-Entwurfs — damit + // der Dialog "hinzufügen" statt "bearbeiten" titelt (siehe IsNewDocumentationDraft). + private Guid? _documentationDraftId; private string _studentName = ""; private List _studentOptions = []; @@ -39,14 +44,29 @@ public partial class StudentVorgaengeViewModel : ObservableObject /// (siehe ) — Parameter ist das gesuchte Tag. public Func>? OnConfirmCreateForWarning { get; set; } + /// Quelle für Schnellbausteine/Vorlagen mit dem aktuellen Stand + /// (Fehlquote, Hausaufgaben, Verspätungen). Optional, damit Tests ohne Mitarbeits-Repositories + /// auskommen — ohne sie entfallen Bausteine und Vorlagen einfach. public StudentVorgaengeViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation, - IStudentRepository students) + IStudentRepository students, StudentWarningEvidenceService? evidence = null) { _vorgaenge = vorgaenge; _documentation = documentation; _students = students; + _evidence = evidence; } + /// Frisch berechnet bei jedem Dialog-Aufruf statt beim Laden gecacht — der Tab kann lange offen + /// bleiben, während im Unterricht weiter Anwesenheit erfasst wird. + public IReadOnlyList BuildQuickBlocks() => + _evidence is null ? [] : StudentEvidenceText.QuickBlocks(_evidence.Build(_studentId)); + + /// Ob ein an OnEditVorgang übergebener Vorgang schon gespeichert ist oder nur ein vorbefüllter + /// Entwurf (HandleWarningNavigation) — steuert den Dialogtitel "anlegen"/"bearbeiten". + public bool IsPersisted(Vorgang vorgang) => _vorgaenge.GetById(vorgang.Id) is not null; + + public bool IsNewDocumentationDraft(Documentation doc) => doc.Id == _documentationDraftId; + partial void OnOnlyOpenChanged(bool value) => Load(_studentId); [RelayCommand] private void Refresh() => Load(_studentId); @@ -80,7 +100,10 @@ public partial class StudentVorgaengeViewModel : ObservableObject .Where(n => !string.IsNullOrEmpty(n))); var item = new VorgangItem(vorgang, names, LinkDocumentation, UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation) - { IsRevealed = revealedIds.Contains(vorgang.Id) }; + { + IsRevealed = revealedIds.Contains(vorgang.Id), + OnCreateFromTemplate = _evidence is null ? null : CreateDocumentationFromTemplate, + }; var linkedIds = vorgang.DocumentationIds.ToHashSet(); foreach (var doc in ownDocs.Where(d => linkedIds.Contains(d.Id))) @@ -146,6 +169,27 @@ public partial class StudentVorgaengeViewModel : ObservableObject Load(_studentId); } + /// Erzeugt aus einer Vorlage (VorgangDocumentationTemplates) einen vorbefüllten Entwurf mit dem + /// aktuellen Stand, öffnet ihn im normalen Dokumentations-Dialog und verknüpft ihn nach dem + /// Speichern mit dem Vorgang — gleicher Abschluss wie CreateAndLinkDocumentation. + private async Task CreateDocumentationFromTemplate(VorgangItem item, string key) + { + if (_evidence is null || OnEditDocumentation is null) return; + var draft = VorgangDocumentationTemplates.Build(key, item.Model, _evidence.Build(_studentId)); + if (draft is null) return; + _documentationDraftId = draft.Id; + Documentation? result; + try { result = await OnEditDocumentation(_studentId, draft); } + finally { _documentationDraftId = null; } + if (result is null) return; + _documentation.Save(result); + item.Model.DocumentationIds.Add(result.Id); + _vorgaenge.Save(item.Model); + Load(_studentId); + var reloaded = Cases.FirstOrDefault(c => c.Model.Id == item.Model.Id); + if (reloaded is not null) reloaded.IsRevealed = true; + } + private async Task EditDocumentation(DocumentationItem doc) { if (OnEditDocumentation is null) return; @@ -199,7 +243,14 @@ public partial class StudentVorgaengeViewModel : ObservableObject if (OnConfirmCreateForWarning is null || OnEditVorgang is null) return; if (!await OnConfirmCreateForWarning(suggestedTag)) return; - var draft = new Vorgang { Title = suggestedTitle, StudentIds = [_studentId], Tags = [suggestedTag] }; + // Nutzer-Feedback: die Zahlen, die die Warnung ausgelöst haben, gleich als Beschreibung + // mitgeben, statt sie im Dialog von Hand abzutippen — bleibt im Dialog frei editierbar. + var description = _evidence is null ? "" + : StudentEvidenceText.StatusBlockForTag(suggestedTag, _evidence.Build(_studentId)) ?? ""; + var draft = new Vorgang + { + Title = suggestedTitle, Description = description, StudentIds = [_studentId], Tags = [suggestedTag], + }; var result = await OnEditVorgang(_studentOptions, draft); if (result is null) return; _vorgaenge.Save(result); diff --git a/LehrerApp.Desktop/ViewModels/Students/VorgangTemplates.cs b/LehrerApp.Desktop/ViewModels/Students/VorgangTemplates.cs new file mode 100644 index 0000000..fa7301b --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/VorgangTemplates.cs @@ -0,0 +1,246 @@ +using System.Globalization; +using System.Text; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Students; + +/// Ein per Klick einfügbarer Textbaustein im Vorgang-Dialog (Nutzer-Feedback: Fehlquoten +/// etc. nicht von Hand abtippen müssen). +public sealed record VorgangQuickBlock(string Label, string Text); + +/// +/// Formuliert die Belege aus als deutschen Fließtext — für +/// Schnellbausteine im Vorgang-Dialog, die Vorbelegung eines aus einer Dashboard-Warnung heraus +/// angelegten Vorgangs und die Dokumentationsvorlagen (). +/// Jeder Block nennt das Stichtagsdatum, weil der Text eingefroren in Vorgang/Dokumentation landet, +/// während die Zahlen dahinter weiterlaufen. +/// +public static class StudentEvidenceText +{ + private static readonly CultureInfo De = new("de-DE"); + /// Die Terminlisten sollen den Beleg stützen, nicht die Beschreibung fluten. + public const int MaxListedEvents = 15; + + public static string AttendanceShort(StudentWarningEvidence e) + { + var b = e.Balance; + var absent = b.Excused + b.Unexcused + b.ExcusePending; + return $"Fehlquote {b.AbsenceRatePercent.ToString("0.#", De)} % im Schuljahr {e.SchoolYear} " + + $"({absent} von {b.TotalChecked} erfassten Stunden; entschuldigt {b.Excused}, " + + $"unentschuldigt {b.Unexcused}, Entschuldigung offen {b.ExcusePending})"; + } + + public static string HomeworkShort(StudentWarningEvidence e) => + $"{e.MissingHomework.Count} nicht bzw. unvollständig erledigte Hausaufgabe{(e.MissingHomework.Count == 1 ? "" : "n")} im Schuljahr {e.SchoolYear}"; + + public static string LatenessShort(StudentWarningEvidence e) => + $"{e.Lateness.Count} Verspätung{(e.Lateness.Count == 1 ? "" : "en")} im Schuljahr {e.SchoolYear}" + + (e.SignificantlyLateCount > 0 ? $" (davon {e.SignificantlyLateCount} erheblich)" : ""); + + public static string AttendanceBlock(StudentWarningEvidence e) => + $"Stand {e.AsOf:dd.MM.yyyy}: {AttendanceShort(e)}."; + + public static string AbsenceListBlock(StudentWarningEvidence e) => ListBlock("Fehltermine", e.Absences); + + public static string HomeworkBlock(StudentWarningEvidence e) => + $"Stand {e.AsOf:dd.MM.yyyy}: {HomeworkShort(e)}.\n" + ListBlock("Betroffene Termine", e.MissingHomework); + + public static string LatenessBlock(StudentWarningEvidence e) => + $"Stand {e.AsOf:dd.MM.yyyy}: {LatenessShort(e)}.\n" + ListBlock("Betroffene Termine", e.Lateness); + + private static string ListBlock(string header, IReadOnlyList events) + { + var sb = new StringBuilder($"{header}:"); + foreach (var ev in events.Take(MaxListedEvents)) sb.Append("\n- ").Append(ev.ListLine); + if (events.Count > MaxListedEvents) sb.Append($"\n- … und {events.Count - MaxListedEvents} weitere"); + return sb.ToString(); + } + + /// Welcher Stand zu welchem Vorgangs-Schlagwort gehört — dieselben drei Tags, mit denen das + /// Dashboard seine Warnungen an Vorgänge knüpft (DashboardViewModel.AbsentismusTag usw.). + public static string? StatusBlockForTag(string tag, StudentWarningEvidence e) => tag.ToLowerInvariant() switch + { + "absentismus" => AttendanceBlock(e) + "\n" + AbsenceListBlock(e), + "hausaufgaben" => HomeworkBlock(e), + "verspätungen" => LatenessBlock(e), + _ => null, + }; + + /// Kurzfassungen für die Tags eines Vorgangs, z.B. als Anlass eines Gesprächs. Ohne passendes + /// Tag bleibt die Liste leer statt ungefragt alle Zahlen einzustreuen. + public static List ShortLinesForTags(IEnumerable tags, StudentWarningEvidence e) + { + var lines = new List(); + foreach (var tag in tags.Select(t => t.ToLowerInvariant()).Distinct()) + { + var line = tag switch + { + "absentismus" => AttendanceShort(e), + "hausaufgaben" => HomeworkShort(e), + "verspätungen" => LatenessShort(e), + _ => null, + }; + if (line is not null) lines.Add(line); + } + return lines; + } + + /// Schnellbausteine für den Vorgang-Dialog — nur solche mit tatsächlichem Inhalt, ein + /// "0 Verspätungen"-Baustein wäre nur Rauschen. + public static List QuickBlocks(StudentWarningEvidence e) + { + var blocks = new List(); + if (e.Balance.TotalChecked > 0) blocks.Add(new("Fehlquote", AttendanceBlock(e))); + if (e.Absences.Count > 0) blocks.Add(new("Fehltermine", AbsenceListBlock(e))); + if (e.MissingHomework.Count > 0) blocks.Add(new("Hausaufgaben", HomeworkBlock(e))); + if (e.Lateness.Count > 0) blocks.Add(new("Verspätungen", LatenessBlock(e))); + return blocks; + } + + /// Übersetzt die lokalen Mitarbeits-Belege in den neutralen Vorlagen-Kontext (die + /// Klassenlehreransicht baut ihren Kontext stattdessen aus WebUntis-Daten, siehe + /// ClassTeacherCaseFacts). + public static VorgangTemplateContext TemplateContext(Vorgang vorgang, StudentWarningEvidence e) => new( + e.StudentId, e.StudentNaturalName, e.AsOf, ShortLinesForTags(vorgang.Tags, e), + new Dictionary + { + [VorgangDocumentationTemplates.AttendanceStatus] = new($"Fehlzeiten-Stand {e.AsOf:dd.MM.yyyy}", + AttendanceBlock(e) + "\n\n" + AbsenceListBlock(e)), + [VorgangDocumentationTemplates.HomeworkStatus] = new($"Hausaufgaben-Stand {e.AsOf:dd.MM.yyyy}", HomeworkBlock(e)), + [VorgangDocumentationTemplates.LatenessStatus] = new($"Verspätungs-Stand {e.AsOf:dd.MM.yyyy}", LatenessBlock(e)), + }); +} + + +/// Ein festhaltbarer Stand ("Fehlzeiten-Stand …") als fertiger Titel + Text. +public sealed record VorgangSnapshot(string Title, string Content); + +/// Neutraler Zahlen-Kontext für — gespeist +/// entweder aus den lokalen Mitarbeits-Einträgen (Schülerdetail, ) +/// oder aus WebUntis (Klassenlehrer-Vorgänge, siehe ClassTeacherCaseFacts). So bleiben die +/// Vorlagentexte an einer Stelle. +/// Kurzfassungen passend zu den Tags des Vorgangs (Anlass). +/// Je Vorlagen-Schlüssel ein festhaltbarer Stand. +public sealed record VorgangTemplateContext(Guid StudentId, string StudentNaturalName, DateOnly AsOf, + IReadOnlyList SituationLines, IReadOnlyDictionary Snapshots); + +/// Standardformulierungen für den Vorgang-Dialog (Nutzer-Feedback: "den Klassenlehrer +/// informiert" u.ä. nicht jedes Mal tippen). Datumsgestempelt, weil sie als Verlaufsnotiz in der +/// Beschreibung landen. Getrennte Listen je Kontext: aus Sicht der Fachlehrkraft (Schülerdetail) wird +/// die Klassenleitung informiert, aus Sicht der Klassenleitung die Fachlehrkräfte. +public static class VorgangPhraseBlocks +{ + public static IReadOnlyList ForStudent(DateOnly today) => + [ + new("Klassenleitung informiert", $"{today:dd.MM.yyyy}: Klassenleitung informiert."), + new("Eltern informiert", $"{today:dd.MM.yyyy}: Erziehungsberechtigte informiert."), + new("Gespräch geführt", $"{today:dd.MM.yyyy}: Gespräch mit der Schülerin/dem Schüler geführt."), + new("Vereinbarung getroffen", $"{today:dd.MM.yyyy}: Vereinbarung getroffen: "), + new("Weiter beobachten", $"{today:dd.MM.yyyy}: Weiter beobachten, Überprüfung am "), + ]; + + public static IReadOnlyList ForClassTeacher(DateOnly today) => + [ + new("Fachlehrkräfte informiert", $"{today:dd.MM.yyyy}: Fachlehrkräfte der Klasse informiert."), + new("Eltern informiert", $"{today:dd.MM.yyyy}: Erziehungsberechtigte informiert."), + new("Gespräch geführt", $"{today:dd.MM.yyyy}: Gespräch mit der Schülerin/dem Schüler geführt."), + new("Attestpflicht angekündigt", $"{today:dd.MM.yyyy}: Attestpflicht angekündigt – Fehlzeiten gelten künftig nur mit ärztlichem Attest als entschuldigt."), + new("Schulsozialarbeit eingebunden", $"{today:dd.MM.yyyy}: Schulsozialarbeit/Beratung eingebunden."), + new("Schulleitung informiert", $"{today:dd.MM.yyyy}: Schulleitung informiert."), + new("Vereinbarung getroffen", $"{today:dd.MM.yyyy}: Vereinbarung getroffen: "), + new("Weiter beobachten", $"{today:dd.MM.yyyy}: Weiter beobachten, Überprüfung am "), + ]; +} + +/// +/// Standardisierte Dokumentationseinträge für einen Vorgang (Nutzer-Feedback: nicht jeden Eintrag +/// komplett von Hand anlegen). Erzeugt nur einen vorbefüllten, noch nicht gespeicherten Entwurf — +/// der landet anschließend im normalen Dokumentations-Dialog, gespeichert wird erst nach bewusster +/// Bestätigung dort. +/// +public static class VorgangDocumentationTemplates +{ + // Stand festhalten — lokal (Schülerdetail) … + public const string AttendanceStatus = "attendance"; + public const string HomeworkStatus = "homework"; + public const string LatenessStatus = "lateness"; + // … bzw. aus WebUntis (Klassenlehrer-Vorgänge) + public const string UntisAbsenceStatus = "untisabsences"; + public const string ClassRegisterStatus = "classregister"; + + public const string Conversation = "conversation"; + public const string ParentCall = "parentcall"; + public const string ParentLetter = "parentletter"; + public const string InformClassTeacher = "informclassteacher"; + public const string InformSubjectTeachers = "informsubjectteachers"; + + public static Documentation? Build(string key, Vorgang vorgang, StudentWarningEvidence e) => + Build(key, vorgang, StudentEvidenceText.TemplateContext(vorgang, e)); + + public static Documentation? Build(string key, Vorgang vorgang, VorgangTemplateContext ctx) + { + var doc = new Documentation { StudentId = ctx.StudentId, Date = ctx.AsOf }; + if (ctx.Snapshots.TryGetValue(key, out var snapshot)) + { + doc.Type = DocumentationType.Incident; + doc.Title = snapshot.Title; + doc.Content = snapshot.Content; + doc.Tags = ["Beobachten"]; + return doc; + } + switch (key) + { + case Conversation: + doc.Type = DocumentationType.Conversation; + doc.Title = $"Gespräch: {vorgang.Title}"; + doc.Participants = [ctx.StudentNaturalName]; + doc.Content = "Anlass:\n" + Occasion(vorgang, ctx) + + "\n\nSicht der Schülerin/des Schülers:\n\n\nVereinbarung:\n\n\nÜberprüfung am:\n"; + return doc; + case InformClassTeacher: + case InformSubjectTeachers: + var who = key == InformClassTeacher ? "Klassenleitung" : "Fachlehrkräfte"; + doc.Type = DocumentationType.Conversation; + doc.Title = $"{who} informiert: {vorgang.Title}"; + doc.Participants = [who]; + doc.Content = $"{who} am {ctx.AsOf:dd.MM.yyyy} informiert über:\n" + Occasion(vorgang, ctx) + + "\n\nWeg (persönlich / Nachricht / E-Mail):\n\n\nRückmeldung / Absprache:\n"; + return doc; + case ParentCall: + doc.Type = DocumentationType.ParentCall; + doc.Title = $"Elternanruf: {vorgang.Title}"; + var points = ctx.SituationLines.Select(l => $"Aktueller Stand: {l}").ToList(); + if (points.Count == 0) points.Add($"Anlass schildern: {vorgang.Title}"); + points.AddRange(["Sicht der Eltern / mögliche Gründe erfragen", "Unterstützung und Vereinbarung abstimmen", + "Nächsten Kontakt vereinbaren"]); + doc.ParentCallData = new ParentCallData + { Points = points.Select(p => new ParentCallPoint { Text = p }).ToList() }; + return doc; + case ParentLetter: + doc.Type = DocumentationType.ParentLetter; + doc.Title = $"Elternbrief: {vorgang.Title}"; + doc.ParentLetterData = new ParentLetterData { DraftContent = LetterDraft(vorgang, ctx) }; + return doc; + default: + return null; + } + } + + private static string Occasion(Vorgang vorgang, VorgangTemplateContext ctx) => + ctx.SituationLines.Count == 0 ? $"- {vorgang.Title}" + : string.Join("\n", ctx.SituationLines.Select(l => $"- {l} (Stand {ctx.AsOf:dd.MM.yyyy})")); + + private static string LetterDraft(Vorgang vorgang, VorgangTemplateContext ctx) + { + var facts = ctx.SituationLines.Count == 0 + ? $"es gibt Anlass zur Sorge ({vorgang.Title})." + : $"mit Stand {ctx.AsOf:dd.MM.yyyy} ergibt sich folgendes Bild:\n" + + string.Join("\n", ctx.SituationLines.Select(l => $"- {l}")); + return $"Sehr geehrte Erziehungsberechtigte von {ctx.StudentNaturalName},\n\n" + + $"ich möchte Sie darüber informieren, dass {facts}\n\n" + + "Ich bitte Sie, mit Ihrem Kind darüber zu sprechen und sich bei Fragen mit mir in Verbindung zu setzen. " + + "Für ein gemeinsames Gespräch stehe ich gerne zur Verfügung.\n\n" + + "Mit freundlichen Grüßen"; + } +} diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml index e8f73f0..a5adc62 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml @@ -126,10 +126,36 @@ - + diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs index 6733be7..fd71dff 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherCasesView.axaml.cs @@ -27,7 +27,10 @@ public partial class ClassTeacherCasesView : UserControl var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return null; - var vm = new VorgangDialogViewModel(rosterStudents, editing); + var cases = DataContext as ClassTeacherCasesViewModel; + var vm = new VorgangDialogViewModel(rosterStudents, editing, + phraseBlocks: VorgangPhraseBlocks.ForClassTeacher(DateOnly.FromDateTime(DateTime.Today)), + studentQuickBlocks: cases is null ? null : cases.QuickBlocksFor); var dialog = new VorgangDialog { DataContext = vm }; var saved = await dialog.ShowDialog(owner); return saved ? vm.Result : null; @@ -53,8 +56,10 @@ public partial class ClassTeacherCasesView : UserControl var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return null; + var isNewDraft = editing is not null + && (DataContext as ClassTeacherCasesViewModel)?.IsNewDocumentationDraft(editing) == true; var vm = new DocumentationDialogViewModel(defaultStudentId, editing, - App.Services.GetRequiredService(), studentOptions); + App.Services.GetRequiredService(), studentOptions, isNewDraft: isNewDraft); var dialog = new DocumentationDialog { DataContext = vm }; var saved = await dialog.ShowDialog(owner); if (!saved) vm.DiscardUnsavedAttachments(); diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index c1c9bc4..38801a4 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher" xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher" + xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared" xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView" x:DataType="vm:ClassTeacherOverviewViewModel"> @@ -98,19 +99,6 @@ - - @@ -424,7 +412,9 @@ + Statuskürzel die Zahl. Alle Details bleiben per Tooltip und Drill-down erhalten. + Das Widget selbst liegt in Views/Shared/CompactMonthCalendar (auch im + Detail-Popup der Dashboard-Warnungen genutzt). --> @@ -441,29 +431,7 @@ DisplayMemberBinding="{Binding DisplayName}" HorizontalAlignment="Stretch" AutomationProperties.Name="Schüler für Monatskalender auswählen"/> - - - - - - - - - - - - - - - - - + diff --git a/LehrerApp.Desktop/Views/ClassTeacher/VorgangDialog.axaml b/LehrerApp.Desktop/Views/ClassTeacher/VorgangDialog.axaml index 7c6994f..6e609f6 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/VorgangDialog.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/VorgangDialog.axaml @@ -1,10 +1,11 @@ @@ -21,8 +22,44 @@ - + + + + + + + + + + - - - - - - - + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs b/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs index eb2916f..e14f488 100644 --- a/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Students/StudentVorgaengeTabView.axaml.cs @@ -29,7 +29,10 @@ public partial class StudentVorgaengeTabView : UserControl var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return null; - var vm = new VorgangDialogViewModel(students, editing); + var tab = DataContext as StudentVorgaengeViewModel; + var vm = new VorgangDialogViewModel(students, editing, tab?.BuildQuickBlocks(), + isNewDraft: editing is not null && tab?.IsPersisted(editing) == false, + phraseBlocks: VorgangPhraseBlocks.ForStudent(DateOnly.FromDateTime(DateTime.Today))); var dialog = new VorgangDialog { DataContext = vm }; var saved = await dialog.ShowDialog(owner); return saved ? vm.Result : null; @@ -54,8 +57,10 @@ public partial class StudentVorgaengeTabView : UserControl var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return null; + var isNewDraft = editing is not null + && (DataContext as StudentVorgaengeViewModel)?.IsNewDocumentationDraft(editing) == true; var vm = new DocumentationDialogViewModel(studentId, editing, - App.Services.GetRequiredService()); + App.Services.GetRequiredService(), isNewDraft: isNewDraft); var dialog = new DocumentationDialog { DataContext = vm }; var saved = await dialog.ShowDialog(owner); if (!saved) vm.DiscardUnsavedAttachments(); diff --git a/TODO.md b/TODO.md index 572a19f..82b1892 100644 --- a/TODO.md +++ b/TODO.md @@ -2005,6 +2005,51 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk `DashboardViewModel.Load()`/`RebuildAttention()`. `DashboardView.axaml` bekam dafür einen neuen `.defused`-Stil (nach `.attendance` deklariert, damit er bei gleicher Klassenanzahl gewinnt) für Severity-Balken und Fehlzeiten-Prozent-Text. + - **Nachtrag, Detail-Popup und Schnellbausteine (September 2026):** Nutzer-Feedback: (a) im + Dashboard-Eintrag mehr erfahren, (b) beim Anlegen von Vorgang/Dokumentation Unterstützung statt + alles von Hand. Gemeinsame Grundlage ist der neue `StudentWarningEvidenceService` (Desktop/ + Services): sammelt für eine/n Schüler*in Fehlquote (`AttendanceBalanceService`), Fehltermine, + Verspätungen und offene Hausaufgaben im laufenden Schuljahr — bewusst nach exakt denselben + Zählregeln wie die drei `Load*Warnings`-Methoden, damit Popup/Bausteine dieselben Zahlen zeigen + wie die Warnzeile. (a) Jede der drei Warnungen hat jetzt einen Button **"Details ▾"** (Flyout per + Klick, nicht Hover — ein Klick auf die Zeile navigiert bereits, und das Popup enthält einen + blätterbaren Kalender) mit Kennzahlen, Terminliste und dem recycelten Monats-Widget der + Klassenlehrer-Übersicht: "Monat auf einen Blick" wurde als `Views/Shared/CompactMonthCalendar` + herausgelöst und wird von `ClassTeacherOverviewView` und dem neuen `StudentWarningDetailView` + gleichermaßen genutzt (gleiche Kürzel/Farben U/A/E/V/H, `ClassTeacherCompactCalendarDay`). Der + Popup-Inhalt (`StudentWarningDetailViewModel`) wird über `AttentionItem.Detail` erst beim Öffnen + berechnet. (b) `StudentEvidenceText` formuliert die Belege als Text mit Stichtag: + **Schnellbausteine** im Vorgang-Dialog (Fehlquote, Fehltermine, Hausaufgaben, Verspätungen — + nur solche mit Inhalt, werden angehängt statt ersetzt), die automatische Vorbelegung der + Beschreibung beim Anlegen aus einer Dashboard-Warnung, und **Dokumentationsvorlagen** + (`VorgangDocumentationTemplates`, Menü "+ Aus Vorlage ▾" im Vorgang): Stand Fehlzeiten/ + Hausaufgaben/Verspätungen festhalten, Gespräch mit Schüler*in, Elternanruf vorbereiten (Punkte + mit aktuellem Stand), Elternbrief entwerfen. Vorlagen erzeugen nur einen Entwurf im normalen + Dokumentations-Dialog (Titel "hinzufügen" über neues `isNewDraft`), gespeichert und mit dem + Vorgang verknüpft wird erst nach Bestätigung. **Bewusst nur im schülerbezogenen Vorgänge-Tab** + — der Klassenlehrer-Tab nutzt dieselben Dialoge ohne Bausteine (Parameter optional); dort + liefern die Zahlen ohnehin WebUntis statt der lokalen Mitarbeits-Einträge. + - **Nachtrag 2, Wochentage, Standardformulierungen, Klassenlehrer-Bausteine (September 2026):** + (1) Bugfix: der kompakte Monatskalender begann jeden Monat oben links, der 1. stand also + unabhängig vom Wochentag in der Montags-Spalte. `CompactMonthCalendar` richtet jetzt selbst aus + (Kopfzeile Mo–So, Leerzellen vor dem Monatsersten, `BuildCells`) — bewusst in der Anzeige statt in + den beiden Buildern, deren Ergebnis "ein Eintrag je Kalendertag" für Tests/Aufrufer gleich bleibt; + wirkt damit in der Klassenlehrer-Übersicht und im Dashboard-Popup. (2) Nutzer-Wunsch "den + Klassenlehrer informiert": neue **Standardformulierungen** im Vorgang-Dialog + (`VorgangPhraseBlocks`, datumsgestempelte Verlaufszeile, angehängt statt ersetzt) — im + Schülerdetail u.a. "Klassenleitung informiert", im Klassenlehrer-Tab u.a. "Fachlehrkräfte + informiert", "Attestpflicht angekündigt", "Schulsozialarbeit eingebunden", "Schulleitung + informiert"; dazu die Vorlagen "Klassenleitung informieren" bzw. "Fachlehrkräfte informieren". + (3) Der **Klassenlehrer-Vorgänge-Tab** bekommt Zahlen-Bausteine und Vorlagen aus WebUntis + (`ClassTeacherStudentFacts`: Fehlstunden/-tage über alle Fächer, unentschuldigte und ganze + Fehltage, Verspätungen, Klassenbucheinträge inkl. Hausaufgaben-Einträgen), erst beim Öffnen eines + Dialogs aus dem Untis-Cache geladen; scheitert der Abruf, fehlen nur die Zahlen. Die + Schülerbausteine im Dialog folgen der Mehrfachauswahl (bei mehreren Beteiligten mit Namen). + Vorlagentexte liegen dafür jetzt an einer Stelle hinter einem neutralen `VorgangTemplateContext` + (lokal aus `StudentEvidenceText.TemplateContext`, im Klassenlehrer-Tab aus + `ClassTeacherStudentFacts.TemplateContext`). Vorlagen im Klassenlehrer-Tab beziehen sich auf + die/den erste/n Beteiligte/n des Vorgangs, der Dokumentations-Dialog bietet dann nur diese Person + an (sonst passten die eingesetzten Zahlen nicht zur gewählten Person). **Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026):** Nutzer-Feedback: beim Planen der nächsten Woche musste nach jedem kurzen Abstecher in einen anderen Bereich der Sidebar wieder