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, }; // Die Breite richtet sich nach der DRAWBOX-Deklaration im Layout-Skript (fest, unabhängig // von der Größenwahl) - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit // skalieren, würde "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber // nur einen Teil davon - und eine größere Skalierung als 1 liefe über die Box hinaus und // würde am rechten Rand abgeschnitten (SVG overflow="hidden"). var contentWidth = width; var cellHeight = 10 * scale; // War 10 - bei 3 Monaten nebeneinander größer als eine einzelne Tageskachel und zog damit // spürbar Platz von den Kacheln ab, ohne selbst als Inhalt wahrgenommen zu werden. var monthGapX = 6f; var first = options.NormalizedStartMonth; var monthCount = options.NormalizedMonthCount; var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount; var cellWidth = monthWidth / 7; // Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war "scale", // also bis zu 1mm auf jeder Seite - bei einer ~7mm breiten Zelle ein gutes Viertel reiner // Leerraum). Die Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus. var cellInset = scale * .4f; var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f; 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 gridStartY = y; var maxWeeks = 0; 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); maxWeeks = Math.Max(maxWeeks, weeks); var xOffset = monthIndex * (monthWidth + monthGapX); var monthY = gridStartY; commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale, monthWidth, current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")), DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true)); monthY += 11 * scale; for (var column = 0; column < 7; column++) commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale, cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280", Bold: true)); monthY += 9 * scale; foreach (var day in days) { var index = offset + day.Date.Day - 1; var column = index % 7; var row = index / 7; var x = xOffset + column * cellWidth; var cellY = monthY + row * cellHeight; commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset, cellHeight - cellInset, cellCornerRadius, "#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 = gridStartY + 11 * scale + 9 * scale + maxWeeks * cellHeight + monthGapX; 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); } } /// 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, }; // Breite bleibt an die DRAWBOX-Deklaration im Layout-Skript gebunden (siehe // StudentAttendanceCalendarDrawingBuilder) - nur Zeilenhöhe/Schrift skalieren mit "Größe". var contentWidth = width; var rowHeight = 12 * 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(); 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; // Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen- // Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei // 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde. const float dateColumnX = 2, dateColumnWidth = 38; const float extentColumnX = 44, extentColumnWidth = 66; const float statusColumnX = 114, statusColumnWidth = 54; commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6")); commands.Add(new DrawStringEx(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true)); y += rowHeight; if (rows.Count == 0) { commands.Add(new DrawStringEx(2, y + 2 * scale, rowHeight, contentWidth - 4, "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(dateColumnX, y + scale, rowHeight - scale, dateColumnWidth, row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); commands.Add(new DrawStringEx(extentColumnX, y + scale, rowHeight - scale, extentColumnWidth, extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151")); commands.Add(new DrawStringEx(statusColumnX, y + scale, rowHeight - scale, statusColumnWidth, 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( 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 matchName = $"{student.FirstName} {student.LastName}"; var rosterName = rosterCache.GetByClass(className) .FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName ?? matchName; 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); } 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 matchName = $"{student.FirstName} {student.LastName}"; var rosterName = rosterCache.GetByClass(className) .FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName ?? matchName; 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)); } }