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