diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 772cd1e..5d16d58 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -2,6 +2,7 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Templating; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -71,6 +72,61 @@ public sealed class ClassTeacherViewModelsTests Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet } + [Fact] + public void Kompaktkalender_KannAufEinzelnenSchuelerEingeschraenktWerden() + { + var month = new DateOnly(2026, 9, 1); + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90, + ["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90, + ["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false), + }; + + var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month, + "Ada Müller"); + + Assert.Equal("U", days[2].SignalCode); + Assert.False(days[3].HasSignal); + } + + [Fact] + public void ElternbriefKalender_LiefertPortablenDrawingPlatzhalterFuerSchueler() + { + var month = new DateOnly(2026, 9, 1); + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90, + ["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90, + ["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false), + }; + + var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", month, absences, []); + + Assert.True(drawing.ContentHeight > 0); + Assert.Contains(drawing.Commands.OfType(), c => c.Text == "Ada Müller"); + Assert.Contains(drawing.Commands.OfType(), c => c.Text == "U"); + Assert.DoesNotContain(drawing.Commands.OfType(), c => c.Text == "E"); + } + + [Fact] + public void ElternbriefKalender_UnterstuetztEinBisDreiMonateUndGroessen() + { + var start = new DateOnly(2026, 9, 18); + var small = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", + new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Small), [], []); + var large = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", + new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []); + var labels = large.Commands.OfType().Select(c => c.Text).ToList(); + + Assert.Contains("September 2026", labels); + Assert.Contains("Oktober 2026", labels); + Assert.Contains("November 2026", labels); + Assert.True(large.ContentHeight > small.ContentHeight); + } + [Fact] public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() { diff --git a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs index d0b86b6..5db426f 100644 --- a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Templating; using Xunit; @@ -54,6 +55,27 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } + [Fact] + public void AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert() + { + var store = StoreWithTemplate(new PlaceholderDefinition( + StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)); + var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []); + var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store, + new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), _ => drawing); + var output = Path.Combine(_directory, "Kalender.pdf"); + + Assert.True(vm.UsesAttendanceCalendar); + vm.SetAttendanceCalendarOptions(new AttendanceCalendarOptions( + new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large)); + Assert.True(vm.AttendanceCalendarConfigured); + Assert.Contains("August 2026", vm.AttendanceCalendarSummary); + Assert.Contains("3 Monate", vm.AttendanceCalendarSummary); + Assert.Contains("Groß", vm.AttendanceCalendarSummary); + Assert.True(vm.Generate(output)); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); + } + private CreateLetterDialogViewModel Build(Student student, TemplateStore store) => new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([])); @@ -65,8 +87,18 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable var y = 20; foreach (var definition in definitions) { - var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT"; - lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}"); + var element = definition.Type switch + { + PlaceholderType.Multiline => "TEXTBOX", + PlaceholderType.Drawing => "DRAWBOX", + _ => "TEXT", + }; + lines.Add(element switch + { + "TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}", + "DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}", + _ => $"TEXT 20 {y} ${definition.Name}", + }); y += 20; } TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary()); diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index a432af7..04c57cf 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -229,6 +229,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/Services/LetterDialogs.cs b/LehrerApp.Desktop/Services/LetterDialogs.cs index 755a8f9..8942d68 100644 --- a/LehrerApp.Desktop/Services/LetterDialogs.cs +++ b/LehrerApp.Desktop/Services/LetterDialogs.cs @@ -19,7 +19,8 @@ public static class LetterDialogs App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - App.Services.GetRequiredService()); + App.Services.GetRequiredService(), + options => App.Services.GetRequiredService().Build(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 5887794..93ec3e5 100644 --- a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs +++ b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs @@ -15,7 +15,7 @@ public static class LetterPlaceholderBuilder { public static Dictionary BuildStandardValues( Student student, Contact? contact, LearningGroup? group, DateOnly date, - string letterText, string teacherName) + string letterText, string teacherName, DrawingValue? attendanceCalendar = 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))); @@ -29,6 +29,7 @@ public static class LetterPlaceholderBuilder ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""), ["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), }; } @@ -36,6 +37,7 @@ public static class LetterPlaceholderBuilder { TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), + DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0, _ => false, }; } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs index e031329..ef971c3 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs @@ -19,7 +19,8 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools; /// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen. public class LetterTemplateTools( TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer, - IStudentRepository students, IGroupRepository groups) + IStudentRepository students, IGroupRepository groups, + StudentAttendanceCalendarService? attendanceCalendars = null) { [Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")] public List ListLetterTemplates() @@ -73,7 +74,8 @@ public class LetterTemplateTools( var group = groupId is { } gid ? groups.GetById(gid) : null; var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today); - var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName); + var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, + teacherName, attendanceCalendars?.Build(student, date)); 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 new file mode 100644 index 0000000..cd76cee --- /dev/null +++ b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.ClassTeacher; +using LehrerApp.Templating; + +namespace LehrerApp.Desktop.Services; + +public enum AttendanceCalendarSize { Small, Medium, Large } + +public sealed record AttendanceCalendarOptions(DateOnly StartMonth, int MonthCount, + AttendanceCalendarSize Size = AttendanceCalendarSize.Medium) +{ + public DateOnly NormalizedStartMonth => new(StartMonth.Year, StartMonth.Month, 1); + public int NormalizedMonthCount => Math.Clamp(MonthCount, 1, 3); +} + +/// Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben +/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird. +public static class StudentAttendanceCalendarDrawingBuilder +{ + public const string PlaceholderName = "Student.AttendanceCalendar"; + + public static DrawingValue Build(string studentName, DateOnly month, + IReadOnlyList absences, + IReadOnlyList registerEntries) => + Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries); + + public static DrawingValue Build(string studentName, AttendanceCalendarOptions options, + IReadOnlyList absences, + IReadOnlyList registerEntries) + { + const float width = 170; + var scale = options.Size switch + { + AttendanceCalendarSize.Small => .7f, + AttendanceCalendarSize.Large => 1f, + _ => .85f, + }; + var contentWidth = width * scale; + var cellWidth = contentWidth / 7; + var cellHeight = 10 * scale; + 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 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); + var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries, + 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, + current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")), + DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true)); + y += 7 * scale; + for (var column = 0; column < 7; column++) + commands.Add(new DrawStringEx(column * cellWidth, y, 6 * scale, cellWidth, weekdays[column], + DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true)); + y += 7 * scale; + + foreach (var day in days) + { + var index = offset + day.Date.Day - 1; + var column = index % 7; + var row = index / 7; + var x = column * cellWidth; + var cellY = y + row * cellHeight; + commands.Add(new DrawRectangle(x + scale, cellY, cellWidth - 2 * scale, cellHeight - scale, + "#D1D5DB", .35f, day.HasSignal ? day.SignalColorHex : "#FFFFFF")); + commands.Add(new DrawStringEx(x, cellY + scale, cellHeight - 2 * scale, cellWidth, + day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale, + Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal)); + } + y += weeks * cellHeight + monthGap; + } + + commands.Add(new DrawStringEx(0, y, 8 * scale, contentWidth, + "U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch", + DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280")); + return new DrawingValue(commands, y + 9 * scale); + } +} + +/// Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines +/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus. +public sealed class StudentAttendanceCalendarService( + WebUntisSettingsService settings, + IUntisAbsenceCacheRepository absenceCache, + IUntisClassRegisterCacheRepository registerCache, + IUntisStudentRosterCacheRepository rosterCache) +{ + public DrawingValue Build(Student student, DateOnly month) => + Build(student, new AttendanceCalendarOptions(month, 1)); + + public DrawingValue Build(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)) + .ToList(); + var register = registerCache.GetByClassAndRange(className, start, end) + .Select(e => new UntisForeignClassRegisterEventDto(e.ClassName, e.Date, e.Subject, e.StudentName, + e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text)) + .ToList(); + return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options, + ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register); + } +} diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 9e84708..c60126d 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -18,6 +18,10 @@ public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger } /// Schlagwort gezielt dämpfen kann, siehe . public enum VorgangScoreDomain { Attendance, Lateness, Classbook } +/// Auswahlbereich des kompakten Monatskalenders. Ein leerer Schülername steht für die +/// bisherige Klassen-Gesamtansicht. +public sealed record ClassTeacherCalendarScope(string? StudentName, string DisplayName); + public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, string? AbsenceTooltip, bool HasRecentClassRegisterEntry) { @@ -424,6 +428,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public ObservableCollection PatternNotices { get; } = []; public ObservableCollection OpenExcuses { get; } = []; public ObservableCollection CompactMonthDays { get; } = []; + public ObservableCollection CalendarScopes { get; } = []; [ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private int _activeTabIndex; @@ -453,6 +458,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject /// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte. [ObservableProperty] private int _ownDocumentationFollowUpCount; [ObservableProperty] private int _ownDocumentationCriticalCount; + [ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope; // Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein // eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen. @@ -501,8 +507,17 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount; public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " + $"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt"; - public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy", - System.Globalization.CultureInfo.GetCultureInfo("de-DE")); + public string CompactMonthLabel + { + get + { + var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy", + System.Globalization.CultureInfo.GetCultureInfo("de-DE")); + return SelectedCalendarScope?.StudentName is { Length: > 0 } + ? $"{month} · {SelectedCalendarScope.DisplayName}" + : $"{month} · gesamte Klasse"; + } + } public Func? OnNavigateToSettings { get; set; } public Func? OnNavigateToWorkload { get; set; } @@ -534,6 +549,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow)); partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts)); partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts)); + partial void OnSelectedCalendarScopeChanged(ClassTeacherCalendarScope? value) + { + OnPropertyChanged(nameof(CompactMonthLabel)); + if (CalendarScopes.Count > 0) RebuildCompactMonthFromCachedData(); + } partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSelectedRosterFilterChanged(int value) { @@ -548,7 +568,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject { ActiveTabIndex = 0; Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); - OpenExcuses.Clear(); CompactMonthDays.Clear(); OpenExcuseOverflowCount = 0; + OpenExcuses.Clear(); CompactMonthDays.Clear(); CalendarScopes.Clear(); SelectedCalendarScope = null; + OpenExcuseOverflowCount = 0; } // Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen @@ -678,6 +699,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject holidayWeekdaysExcluded, termStart, classRegisterEvents, _patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row); + CalendarScopes.Add(new ClassTeacherCalendarScope(null, "Gesamte Klasse")); + foreach (var student in students.OrderBy(s => s.DisplayName)) + CalendarScopes.Add(new ClassTeacherCalendarScope(student.DisplayName, + ClassTeacherOverviewViewModel.DisplayStudentName(student.DisplayName))); + SelectedCalendarScope = CalendarScopes[0]; + StudentCount = Roster.Count; TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused); @@ -724,7 +751,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject private void OpenMonthlyCalendar() { var today = DateOnly.FromDateTime(DateTime.Today); - DetailsTab.StudentFilter = ""; + DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? ""; DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1); ActiveTabIndex = 2; DetailsTab.ShowCalendarCommand.Execute(null); @@ -735,15 +762,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject { CompactMonthDays.Clear(); foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1), - absences, registerEntries, today)) + absences, registerEntries, today, SelectedCalendarScope?.StudentName)) CompactMonthDays.Add(day); } + private void RebuildCompactMonthFromCachedData() + { + if (_lastAbsences is null || _lastClassRegisterEvents is null) return; + var today = DateOnly.FromDateTime(DateTime.Today); + BuildCompactMonth(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(_lastAbsences), + _lastClassRegisterEvents, today); + } + public static IReadOnlyList BuildCompactMonthDays(DateOnly month, IReadOnlyList absences, - IReadOnlyList registerEntries, DateOnly today) + IReadOnlyList registerEntries, DateOnly today, + string? studentName = null) { var first = new DateOnly(month.Year, month.Month, 1); + if (!string.IsNullOrWhiteSpace(studentName)) + { + absences = absences.Where(a => UntisNameMatching.NamesMatch(a.StudentName, studentName)).ToList(); + registerEntries = registerEntries.Where(e => UntisNameMatching.NamesMatch(e.StudentName, studentName)).ToList(); + } var registerRows = registerEntries .Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date)) .Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month) @@ -768,6 +809,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject return result; } + internal static string DisplayStudentName(string value) + { + var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0])); + } + private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch { ClassTeacherCalendarEventKind.Unexcused => 0, diff --git a/LehrerApp.Desktop/ViewModels/Students/AttendanceCalendarConfigurationViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/AttendanceCalendarConfigurationViewModel.cs new file mode 100644 index 0000000..d3daaac --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/AttendanceCalendarConfigurationViewModel.cs @@ -0,0 +1,35 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Students; + +public sealed record AttendanceCalendarSizeChoice(AttendanceCalendarSize Value, string DisplayName); + +public partial class AttendanceCalendarConfigurationViewModel : ObservableObject +{ + [ObservableProperty] private DateTimeOffset? _startMonth; + [ObservableProperty] private int _monthCount; + [ObservableProperty] private AttendanceCalendarSizeChoice _selectedSize; + + public IReadOnlyList MonthCounts { get; } = [1, 2, 3]; + public IReadOnlyList Sizes { get; } = + [ + new(AttendanceCalendarSize.Small, "Klein"), + new(AttendanceCalendarSize.Medium, "Standard"), + new(AttendanceCalendarSize.Large, "Groß"), + ]; + + public AttendanceCalendarConfigurationViewModel(AttendanceCalendarOptions options) + { + StartMonth = new DateTimeOffset(options.NormalizedStartMonth.ToDateTime(TimeOnly.MinValue)); + MonthCount = options.NormalizedMonthCount; + SelectedSize = Sizes.First(s => s.Value == options.Size); + } + + public AttendanceCalendarOptions BuildResult() + { + var date = DateOnly.FromDateTime((StartMonth ?? DateTimeOffset.Now).LocalDateTime); + return new AttendanceCalendarOptions(new DateOnly(date.Year, date.Month, 1), MonthCount, + SelectedSize.Value); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs index f305dc6..ae06d96 100644 --- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -12,6 +12,9 @@ public partial class CreateLetterDialogViewModel : ObservableObject private readonly Student _student; private readonly TemplateStore _templates; private readonly ITemplateRenderer _renderer; + private readonly Func? _attendanceCalendarFactory; + private AttendanceCalendarOptions _attendanceCalendarOptions = new( + new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1); [ObservableProperty] private LetterTemplateChoice? _selectedTemplate; [ObservableProperty] private LetterContactChoice? _selectedContact; @@ -21,6 +24,9 @@ public partial class CreateLetterDialogViewModel : ObservableObject [ObservableProperty] private string _teacherName = ""; [ObservableProperty] private string _generationError = ""; [ObservableProperty] private bool _canGenerate; + [ObservableProperty] private bool _usesAttendanceCalendar; + [ObservableProperty] private bool _attendanceCalendarConfigured; + [ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße"; public string StudentName => _student.FullName; public ObservableCollection Templates { get; } = []; @@ -34,9 +40,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf"); public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, - IGroupMembershipRepository memberships, IGroupRepository groups) + IGroupMembershipRepository memberships, IGroupRepository groups, + Func? attendanceCalendarFactory = null) { _student = student; _templates = templates; _renderer = renderer; + _attendanceCalendarFactory = attendanceCalendarFactory; 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)) @@ -45,10 +53,21 @@ public partial class CreateLetterDialogViewModel : ObservableObject RefreshValidation(); } - partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); } + partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) + { + OnPropertyChanged(nameof(SuggestedFileName)); + UsesAttendanceCalendar = TemplateUsesAttendanceCalendar(value); + AttendanceCalendarConfigured = false; + ResetAttendanceCalendarOptions(); + RefreshValidation(); + } partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation(); - partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation(); + partial void OnLetterDateChanged(DateTimeOffset? value) + { + if (!AttendanceCalendarConfigured) ResetAttendanceCalendarOptions(); + RefreshValidation(); + } partial void OnLetterTextChanged(string value) => RefreshValidation(); partial void OnTeacherNameChanged(string value) => RefreshValidation(); @@ -91,7 +110,60 @@ public partial class CreateLetterDialogViewModel : ObservableObject private IReadOnlyDictionary BuildValues() => LetterPlaceholderBuilder.BuildStandardValues( _student, SelectedContact?.Model, SelectedGroup?.Model, - DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName); + DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName, + UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null); + + public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions; + + public void SetAttendanceCalendarOptions(AttendanceCalendarOptions options) + { + _attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth, + MonthCount = options.NormalizedMonthCount }; + AttendanceCalendarConfigured = true; + AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions); + RefreshValidation(); + } + + private void ResetAttendanceCalendarOptions() + { + var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime); + _attendanceCalendarOptions = new AttendanceCalendarOptions(new(date.Year, date.Month, 1), 1); + AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions); + } + + private bool TemplateUsesAttendanceCalendar(LetterTemplateChoice? choice) + { + 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)); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) + { + return false; + } + } + + private static bool UsesPlaceholder(TemplateLayout layout) => + 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 }); + + private static string FormatAttendanceCalendarSummary(AttendanceCalendarOptions options) + { + var month = options.NormalizedStartMonth.ToString("MMMM yyyy", + System.Globalization.CultureInfo.GetCultureInfo("de-DE")); + var size = options.Size switch + { + AttendanceCalendarSize.Small => "Klein", + AttendanceCalendarSize.Large => "Groß", + _ => "Standard", + }; + return $"Ab {month} · {options.NormalizedMonthCount} Monat{(options.NormalizedMonthCount == 1 ? "" : "e")} · {size}"; + } private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value); private static string SanitizeFileName(string value) diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index 6bae859..c1c9bc4 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -436,6 +436,11 @@ Background="Transparent" BorderThickness="0" Padding="6,2" Foreground="{DynamicResource AppAccentTextBrush}"/> + diff --git a/LehrerApp.Desktop/Views/Students/AttendanceCalendarConfigurationDialog.axaml b/LehrerApp.Desktop/Views/Students/AttendanceCalendarConfigurationDialog.axaml new file mode 100644 index 0000000..5a2db97 --- /dev/null +++ b/LehrerApp.Desktop/Views/Students/AttendanceCalendarConfigurationDialog.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + +