diff --git a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs index adc4028..117c8ac 100644 --- a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs @@ -56,7 +56,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable } [Fact] - public void AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert() + public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert() { var store = StoreWithTemplate(new PlaceholderDefinition( StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); @@ -66,7 +66,7 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable var output = Path.Combine(_directory, "Kalender.pdf"); Assert.True(vm.UsesAttendanceCalendar); - vm.SetAttendanceCalendarOptions(new AttendanceCalendarOptions( + await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large)); Assert.True(vm.AttendanceCalendarConfigured); Assert.Contains("August 2026", vm.AttendanceCalendarSummary); @@ -76,6 +76,45 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } + [Fact] + public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus() + { + var store = StoreWithTemplate(new PlaceholderDefinition( + StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); + AttendanceCalendarOptions? requested = null; + var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, + new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), + _ => new DrawingValue([], 0), attendanceDataRefresher: (options, _) => + { requested = options; return Task.CompletedTask; }); + + await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( + new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium)); + + Assert.NotNull(requested); + Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth); + Assert.Equal(2, requested.NormalizedMonthCount); + Assert.False(vm.IsRefreshingAttendanceData); + Assert.Equal("", vm.AttendanceRefreshError); + } + + [Fact] + public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren() + { + var store = StoreWithTemplate(new PlaceholderDefinition( + StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); + var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, + new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), + _ => new DrawingValue([], 0), + attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung.")); + + await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions( + new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium)); + + Assert.True(vm.AttendanceCalendarConfigured); + Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError); + Assert.False(vm.IsRefreshingAttendanceData); + } + [Fact] public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt() { @@ -116,6 +155,42 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } + [Fact] + public void StudentName_WirdAutomatischMitVollemNamenBefuellt() + { + var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), + new PlaceholderDefinition("Datum", PlaceholderType.Date, true), + new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true), + new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true)); + var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store); + vm.LetterText = "Dies ist der Inhalt."; + + Assert.Empty(vm.CustomPlaceholders); + Assert.True(vm.CanGenerate); + var output = Path.Combine(_directory, "StudentName.pdf"); + Assert.True(vm.Generate(output)); + } + + [Fact] + public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar() + { + var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), + new PlaceholderDefinition("Datum", PlaceholderType.Date, true), + new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true)); + var vm = Build(StudentWithContact(null), store); + vm.LetterText = "Dies ist der Inhalt."; + + Assert.Equal("", vm.Anrede); + Assert.False(vm.CanGenerate); + Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase)); + + vm.Anrede = "Sehr geehrte Familie Beispiel,"; + var output = Path.Combine(_directory, "AnredeManuell.pdf"); + + Assert.True(vm.CanGenerate); + Assert.True(vm.Generate(output)); + } + private CreateLetterDialogViewModel Build(Student student, TemplateStore store) => new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([])); diff --git a/LehrerApp.Desktop/Services/LetterDialogs.cs b/LehrerApp.Desktop/Services/LetterDialogs.cs index cfd7eb0..825673f 100644 --- a/LehrerApp.Desktop/Services/LetterDialogs.cs +++ b/LehrerApp.Desktop/Services/LetterDialogs.cs @@ -22,10 +22,27 @@ public static class LetterDialogs App.Services.GetRequiredService(), options => App.Services.GetRequiredService().Build(student, options), options => App.Services.GetRequiredService() - .BuildAbsenceDayList(student, options)); + .BuildAbsenceDayList(student, options), + RefreshAttendanceDataAsync); var dialog = new CreateLetterDialog { DataContext = vm }; var path = await dialog.ShowDialog(owner); if (!string.IsNullOrEmpty(path) && File.Exists(path)) System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); } + + /// Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per + /// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf + /// beim bloßen Öffnen des Briefdialogs). Damit sieht + /// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu + /// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde. + private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token) + { + var className = App.Services.GetRequiredService().HomeroomClassName; + if (string.IsNullOrWhiteSpace(className)) return; + var cache = App.Services.GetRequiredService(); + var start = options.NormalizedStartMonth; + var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1); + await cache.GetAbsencesAsync(className, start, end, token: token); + await cache.GetClassRegisterEventsAsync(className, start, end, token: token); + } } diff --git a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs index 6583fc6..9f98394 100644 --- a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs +++ b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs @@ -26,6 +26,7 @@ public static class LetterPlaceholderBuilder ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""), ["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName), ["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName), + ["Student.Name"] = new TextValue(student.FullName), ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address), ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""), ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""), diff --git a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs index 8eafab8..298900f 100644 --- a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs +++ b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs @@ -43,15 +43,15 @@ public static class StudentAttendanceCalendarDrawingBuilder var monthGap = 6 * scale; var first = options.NormalizedStartMonth; var monthCount = options.NormalizedMonthCount; - var commands = new List - { - new DrawStringEx(0, 0, 7 * scale, contentWidth, "Anwesenheit", - DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true), - new DrawStringEx(0, 7 * scale, 6 * scale, contentWidth, studentName, - DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"), - }; + var commands = new List(); + var y = 0f; + commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Anwesenheit", + DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true)); + y += 14 * scale; + commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName, + DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280")); + y += 10 * scale + 2 * scale; var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" }; - var y = 15 * scale; for (var monthIndex = 0; monthIndex < monthCount; monthIndex++) { var current = first.AddMonths(monthIndex); @@ -59,14 +59,14 @@ public static class StudentAttendanceCalendarDrawingBuilder DateOnly.FromDateTime(DateTime.Today), studentName); var offset = ((int)current.DayOfWeek + 6) % 7; var weeks = (int)Math.Ceiling((offset + days.Count) / 7d); - commands.Add(new DrawStringEx(0, y, 7 * scale, contentWidth, + commands.Add(new DrawStringEx(0, y, 11 * scale, contentWidth, current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")), DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true)); - y += 7 * scale; + y += 11 * scale; for (var column = 0; column < 7; column++) - commands.Add(new DrawStringEx(column * cellWidth, y, 6 * scale, cellWidth, weekdays[column], + commands.Add(new DrawStringEx(column * cellWidth, y, 9 * scale, cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true)); - y += 7 * scale; + y += 9 * scale; foreach (var day in days) { @@ -115,20 +115,20 @@ public static class StudentAbsenceDayListDrawingBuilder UntisNameMatching.NamesMatch(a.StudentName, studentName)) .OrderBy(a => a.Date) .ToList(); - var commands = new List - { - new DrawStringEx(0, 0, 7 * scale, contentWidth, "Fehltage", - DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true), - new DrawStringEx(0, 7 * scale, 6 * scale, contentWidth, studentName, - DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"), - }; - var y = 16 * scale; + var commands = new List(); + var y = 0f; + commands.Add(new DrawStringEx(0, y, 14 * scale, contentWidth, "Fehltage", + DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true)); + y += 14 * scale; + commands.Add(new DrawStringEx(0, y, 10 * scale, contentWidth, studentName, + DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280")); + y += 10 * scale + 2 * scale; commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6")); - commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - 2 * scale, 31 * scale, "Datum", + commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - scale, 31 * scale, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); - commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - 2 * scale, 75 * scale, "Umfang", + commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - scale, 75 * scale, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); - commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - 2 * scale, 55 * scale, "Status", + commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - scale, 55 * scale, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); y += rowHeight; @@ -157,12 +157,12 @@ public static class StudentAbsenceDayListDrawingBuilder : row.FriendlyStatusLabel; var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32"; commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill)); - commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - 2 * scale, 31 * scale, + commands.Add(new DrawStringEx(2 * scale, y + scale, rowHeight - scale, 31 * scale, row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); - commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - 2 * scale, 75 * scale, + commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - scale, 75 * scale, extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); - commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - 2 * scale, 55 * scale, + commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - scale, 55 * scale, status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor, Bold: row.IsUnexcused)); y += rowHeight; diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs index c485bcb..b3885f3 100644 --- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -14,6 +14,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject private readonly ITemplateRenderer _renderer; private readonly Func? _attendanceCalendarFactory; private readonly Func? _absenceDayListFactory; + private readonly Func? _attendanceDataRefresher; private AttendanceCalendarOptions _attendanceCalendarOptions = new( new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1); @@ -21,6 +22,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject [ObservableProperty] private LetterContactChoice? _selectedContact; [ObservableProperty] private LetterGroupChoice? _selectedGroup; [ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now; + [ObservableProperty] private string _anrede = ""; [ObservableProperty] private string _letterText = ""; [ObservableProperty] private string _teacherName = ""; [ObservableProperty] private string _generationError = ""; @@ -29,6 +31,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject [ObservableProperty] private bool _usesAbsenceDayList; [ObservableProperty] private bool _attendanceCalendarConfigured; [ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße"; + [ObservableProperty] private bool _isRefreshingAttendanceData; + [ObservableProperty] private string _attendanceRefreshError = ""; public string StudentName => _student.FullName; public ObservableCollection Templates { get; } = []; @@ -47,11 +51,13 @@ public partial class CreateLetterDialogViewModel : ObservableObject public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, IGroupMembershipRepository memberships, IGroupRepository groups, Func? attendanceCalendarFactory = null, - Func? absenceDayListFactory = null) + Func? absenceDayListFactory = null, + Func? attendanceDataRefresher = null) { _student = student; _templates = templates; _renderer = renderer; _attendanceCalendarFactory = attendanceCalendarFactory; _absenceDayListFactory = absenceDayListFactory; + _attendanceDataRefresher = attendanceDataRefresher; foreach (var template in templates.GetTemplates()) Templates.Add(new(template)); foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact)); foreach (var membership in memberships.GetByStudent(student.Id)) @@ -73,7 +79,12 @@ public partial class CreateLetterDialogViewModel : ObservableObject RebuildCustomPlaceholders(value); RefreshValidation(); } - partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); + partial void OnSelectedContactChanged(LetterContactChoice? value) + { + Anrede = value?.Model.LetterSalutation ?? ""; + RefreshValidation(); + } + partial void OnAnredeChanged(string value) => RefreshValidation(); partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation(); partial void OnLetterDateChanged(DateTimeOffset? value) { @@ -127,6 +138,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName, UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null, UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null); + values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede); foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue(); return values; } @@ -159,19 +171,28 @@ public partial class CreateLetterDialogViewModel : ObservableObject private static readonly HashSet StandardPlaceholderNames = new(StringComparer.Ordinal) { "Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName", - "Student.FirstName", "Student.LastName", "Contact.Name", "Contact.Address", "Contact.Street", + "Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street", "Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear", StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName, }; public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions; - public void SetAttendanceCalendarOptions(AttendanceCalendarOptions options) + public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default) { _attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth, MonthCount = options.NormalizedMonthCount }; AttendanceCalendarConfigured = true; AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions); + AttendanceRefreshError = ""; + if (_attendanceDataRefresher is not null) + { + IsRefreshingAttendanceData = true; + try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); } + catch (WebUntisIntegrationException ex) + { AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; } + finally { IsRefreshingAttendanceData = false; } + } RefreshValidation(); } diff --git a/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml b/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml index 90e1a66..c6986aa 100644 --- a/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml +++ b/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml @@ -32,6 +32,10 @@ + + + + @@ -46,14 +50,20 @@ - - - - - -