diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 5d16d58..74fd0c0 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -127,6 +127,32 @@ public sealed class ClassTeacherViewModelsTests Assert.True(large.ContentHeight > small.ContentHeight); } + [Fact] + public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus() + { + var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1); + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["entsch."], ["Krank"], null, null, true), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 8), "Müller Ada", 1, 2, 90, + ["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 9), "Schmidt Ben", 2, 4, 180, + ["Eng"], [1, 2, 3, 4], ["entsch."], ["Krank"], null, null, true), + }; + + var drawing = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences); + var labels = drawing.Commands.OfType().Select(c => c.Text).ToList(); + + Assert.Contains("03.09.2026", labels); + Assert.Contains("Ganzer Fehltag", labels); + Assert.Contains("08.09.2026", labels); + Assert.Contains("Fehlzeit · 2 Std.", labels); + Assert.Contains("Entschuldigt", labels); + Assert.Contains("Unentschuldigt", labels); + Assert.DoesNotContain("09.09.2026", labels); + } + [Fact] public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() { diff --git a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs index 5db426f..137a937 100644 --- a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs @@ -76,6 +76,23 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } + [Fact] + public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt() + { + var store = StoreWithTemplate(new PlaceholderDefinition( + StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); + var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel", + new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []); + var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, + new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), + attendanceCalendarFactory: null, absenceDayListFactory: _ => drawing); + + Assert.False(vm.UsesAttendanceCalendar); + Assert.True(vm.UsesAbsenceDayList); + Assert.True(vm.UsesAttendanceAdvancedContent); + Assert.True(vm.CanGenerate); + } + 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 8942d68..cfd7eb0 100644 --- a/LehrerApp.Desktop/Services/LetterDialogs.cs +++ b/LehrerApp.Desktop/Services/LetterDialogs.cs @@ -20,7 +20,9 @@ public static class LetterDialogs App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - options => App.Services.GetRequiredService().Build(student, options)); + options => App.Services.GetRequiredService().Build(student, options), + options => App.Services.GetRequiredService() + .BuildAbsenceDayList(student, options)); var dialog = new CreateLetterDialog { DataContext = vm }; var path = await dialog.ShowDialog(owner); if (!string.IsNullOrEmpty(path) && File.Exists(path)) diff --git a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs index 93ec3e5..6583fc6 100644 --- a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs +++ b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs @@ -15,7 +15,8 @@ public static class LetterPlaceholderBuilder { public static Dictionary BuildStandardValues( Student student, Contact? contact, LearningGroup? group, DateOnly date, - string letterText, string teacherName, DrawingValue? attendanceCalendar = null) + string letterText, string teacherName, DrawingValue? attendanceCalendar = null, + DrawingValue? absenceDays = null) { var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x))); var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x))); @@ -30,6 +31,7 @@ public static class LetterPlaceholderBuilder ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""), ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""), [StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0), + [StudentAbsenceDayListDrawingBuilder.PlaceholderName] = absenceDays ?? new DrawingValue([], 0), }; } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs index ef971c3..099fdae 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs @@ -75,7 +75,8 @@ public class LetterTemplateTools( var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today); var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, - teacherName, attendanceCalendars?.Build(student, date)); + teacherName, attendanceCalendars?.Build(student, date), + attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1))); foreach (var (name, raw) in extraValues ?? []) { var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name); diff --git a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs index cd76cee..8eafab8 100644 --- a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs +++ b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs @@ -91,6 +91,87 @@ public static class StudentAttendanceCalendarDrawingBuilder } } +/// Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen. +public static class StudentAbsenceDayListDrawingBuilder +{ + public const string PlaceholderName = "Student.AbsenceDays"; + + public static DrawingValue Build(string studentName, AttendanceCalendarOptions options, + IReadOnlyList absences) + { + const float width = 170; + var scale = options.Size switch + { + AttendanceCalendarSize.Small => .75f, + AttendanceCalendarSize.Large => 1f, + _ => .88f, + }; + var contentWidth = width * scale; + var rowHeight = 9 * scale; + var start = options.NormalizedStartMonth; + var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1); + var rows = absences + .Where(a => a.Date >= start && a.Date <= end && + 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; + 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", + DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); + commands.Add(new DrawStringEx(36 * scale, y + scale, rowHeight - 2 * 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", + DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); + y += rowHeight; + + if (rows.Count == 0) + { + commands.Add(new DrawStringEx(2 * scale, y + 2 * scale, rowHeight, contentWidth - 4 * scale, + "Keine Fehltage im gewählten Zeitraum", DrawingTextAlignment.AlignLeft, 8 * scale, + Color: "#6B7280", Italic: true)); + y += rowHeight + 3 * scale; + } + else + { + for (var index = 0; index < rows.Count; index++) + { + var row = rows[index]; + var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB"; + var extent = row.CountsAsFullDay + ? "Ganzer Fehltag" + : row.TotalAbsentPeriods > 0 + ? $"Fehlzeit · {row.TotalAbsentPeriods} Std." + : $"Fehlzeit · {row.TotalAbsentMinutes} Min."; + var status = row.IsUnexcused + ? "Unentschuldigt" + : row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase) + ? "Entschuldigt" + : 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, + 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, + extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); + commands.Add(new DrawStringEx(113 * scale, y + scale, rowHeight - 2 * scale, 55 * scale, + status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor, + Bold: row.IsUnexcused)); + y += rowHeight; + } + } + return new DrawingValue(commands, y); + } +} + /// Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines /// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus. public sealed class StudentAttendanceCalendarService( @@ -126,4 +207,24 @@ public sealed class StudentAttendanceCalendarService( return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options, ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register); } + + public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options) + { + var className = settings.HomeroomClassName; + if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0); + + var first = options.NormalizedStartMonth; + var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1); + var rosterName = rosterCache.GetByClass(className) + .FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, student.FullName))?.DisplayName + ?? student.FullName; + var start = first.Year * 10000 + first.Month * 100 + first.Day; + var end = last.Year * 10000 + last.Month * 100 + last.Day; + var absences = absenceCache.GetByClassAndRange(className, start, end) + .Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date, + e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note, + e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay)); + return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options, + ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)); + } } diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs index ae06d96..2898c9f 100644 --- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -13,6 +13,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject private readonly TemplateStore _templates; private readonly ITemplateRenderer _renderer; private readonly Func? _attendanceCalendarFactory; + private readonly Func? _absenceDayListFactory; private AttendanceCalendarOptions _attendanceCalendarOptions = new( new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1); @@ -25,6 +26,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject [ObservableProperty] private string _generationError = ""; [ObservableProperty] private bool _canGenerate; [ObservableProperty] private bool _usesAttendanceCalendar; + [ObservableProperty] private bool _usesAbsenceDayList; [ObservableProperty] private bool _attendanceCalendarConfigured; [ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße"; @@ -36,15 +38,18 @@ public partial class CreateLetterDialogViewModel : ObservableObject public bool HasIssues => Issues.Count > 0; public bool HasNoTemplates => Templates.Count == 0; public bool HasNoContacts => Contacts.Count == 0; + public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList; public string SuggestedFileName => SanitizeFileName( $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf"); public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, IGroupMembershipRepository memberships, IGroupRepository groups, - Func? attendanceCalendarFactory = null) + Func? attendanceCalendarFactory = null, + Func? absenceDayListFactory = null) { _student = student; _templates = templates; _renderer = renderer; _attendanceCalendarFactory = attendanceCalendarFactory; + _absenceDayListFactory = absenceDayListFactory; 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)) @@ -56,7 +61,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); - UsesAttendanceCalendar = TemplateUsesAttendanceCalendar(value); + UsesAttendanceCalendar = TemplateUsesPlaceholder(value, + StudentAttendanceCalendarDrawingBuilder.PlaceholderName); + UsesAbsenceDayList = TemplateUsesPlaceholder(value, + StudentAbsenceDayListDrawingBuilder.PlaceholderName); + OnPropertyChanged(nameof(UsesAttendanceAdvancedContent)); AttendanceCalendarConfigured = false; ResetAttendanceCalendarOptions(); RefreshValidation(); @@ -111,7 +120,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject private IReadOnlyDictionary BuildValues() => LetterPlaceholderBuilder.BuildStandardValues( _student, SelectedContact?.Model, SelectedGroup?.Model, DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName, - UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null); + UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null, + UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null); public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions; @@ -131,14 +141,14 @@ public partial class CreateLetterDialogViewModel : ObservableObject AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions); } - private bool TemplateUsesAttendanceCalendar(LetterTemplateChoice? choice) + private bool TemplateUsesPlaceholder(LetterTemplateChoice? choice, string placeholderName) { if (choice is null) return false; try { var loaded = _templates.Load(choice.Model); - return UsesPlaceholder(loaded.Layout) || - (loaded.ContinuationLayout is not null && UsesPlaceholder(loaded.ContinuationLayout)); + return UsesPlaceholder(loaded.Layout, placeholderName) || + (loaded.ContinuationLayout is not null && UsesPlaceholder(loaded.ContinuationLayout, placeholderName)); } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { @@ -146,11 +156,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject } } - private static bool UsesPlaceholder(TemplateLayout layout) => + private static bool UsesPlaceholder(TemplateLayout layout, string placeholderName) => layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements)) .Concat(layout.ContentFlows.SelectMany(f => f.Elements)) - .Any(e => e is DrawBoxElement { Placeholder: StudentAttendanceCalendarDrawingBuilder.PlaceholderName } - or FlowDrawBoxElement { Placeholder: StudentAttendanceCalendarDrawingBuilder.PlaceholderName }); + .Any(e => e is DrawBoxElement draw && draw.Placeholder == placeholderName || + e is FlowDrawBoxElement flow && flow.Placeholder == placeholderName); private static string FormatAttendanceCalendarSummary(AttendanceCalendarOptions options) { diff --git a/LehrerApp.Desktop/Views/Students/AttendanceCalendarConfigurationDialog.axaml b/LehrerApp.Desktop/Views/Students/AttendanceCalendarConfigurationDialog.axaml index 5a2db97..bb7b248 100644 --- a/LehrerApp.Desktop/Views/Students/AttendanceCalendarConfigurationDialog.axaml +++ b/LehrerApp.Desktop/Views/Students/AttendanceCalendarConfigurationDialog.axaml @@ -4,12 +4,12 @@ xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters" x:Class="LehrerApp.Desktop.Views.Students.AttendanceCalendarConfigurationDialog" x:DataType="vm:AttendanceCalendarConfigurationViewModel" - Title="Anwesenheitskalender konfigurieren" Width="440" Height="360" + Title="Anwesenheitsdaten konfigurieren" Width="440" Height="360" CanResize="False" WindowStartupLocation="CenterOwner"> - - + diff --git a/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml b/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml index 8d77fa2..6762981 100644 --- a/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml +++ b/LehrerApp.Desktop/Views/Students/CreateLetterDialog.axaml @@ -44,11 +44,11 @@ - - +