Files
LehrerApp/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs
T
adminandClaude Sonnet 5 86a97d91a7
CI / build-and-test (push) Waiting to run
fix: Anwesenheitskalender/Fehltagesliste nutzen die real deklarierte Boxgröße
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170)
aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen
Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in
"pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template)
blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer
- sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF.

CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real
deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template
(LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die
Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:27:05 +02:00

273 lines
16 KiB
C#

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);
}
/// <summary>Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben
/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird.</summary>
public static class StudentAttendanceCalendarDrawingBuilder
{
public const string PlaceholderName = "Student.AttendanceCalendar";
public static DrawingValue Build(string studentName, DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries) =>
Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries);
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, float contentWidth = 170,
float millimeterScale = 1)
{
var scale = options.Size switch
{
AttendanceCalendarSize.Small => .7f,
AttendanceCalendarSize.Large => 1f,
_ => .85f,
};
// contentWidth/millimeterScale kommen aus der tatsächlichen DRAWBOX/FLOWDRAWBOX-Deklaration
// der jeweiligen Vorlage (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster
// ist in Millimetern entworfen; millimeterScale rechnet das in die Koordinaten-Einheit der
// Vorlage um (1, wenn die Vorlage schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das
// Raster bei einer in "pt" deklarierten Vorlage nur rund ein Drittel der vorgesehenen Größe
// erreichen. Schriftgrößen (FontSize) sind davon unabhängig immer echte Punktgrößen.
// contentWidth ist unabhängig von der Größenwahl fest - 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").
// Die Defaults (170/1) greifen nur, wenn die Vorlage nicht ermittelt werden kann (z.B.
// Vorschau ohne Kontext).
var cellHeight = 10 * scale * millimeterScale;
// War 10mm - 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 * millimeterScale;
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"
// mm, 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 * millimeterScale;
var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f;
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Anwesenheit",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale * millimeterScale;
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
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 * millimeterScale, monthWidth,
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true));
monthY += 11 * scale * millimeterScale;
for (var column = 0; column < 7; column++)
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale * millimeterScale,
cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280",
Bold: true));
monthY += 9 * scale * millimeterScale;
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 * millimeterScale,
cellHeight - 2 * scale * millimeterScale, cellWidth,
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
}
}
y = gridStartY + 11 * scale * millimeterScale + 9 * scale * millimeterScale + maxWeeks * cellHeight + monthGapX;
commands.Add(new DrawStringEx(0, y, 8 * scale * millimeterScale, 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 * millimeterScale);
}
}
/// <summary>Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen.</summary>
public static class StudentAbsenceDayListDrawingBuilder
{
public const string PlaceholderName = "Student.AbsenceDays";
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, float contentWidth = 170,
float millimeterScale = 1)
{
var scale = options.Size switch
{
AttendanceCalendarSize.Small => .75f,
AttendanceCalendarSize.Large => 1f,
_ => .88f,
};
// contentWidth/millimeterScale: siehe StudentAttendanceCalendarDrawingBuilder - contentWidth
// bleibt an die tatsächliche DRAWBOX/FLOWDRAWBOX-Deklaration im Layout-Skript gebunden,
// millimeterScale rechnet das mm-entworfene Raster in die Koordinaten-Einheit der Vorlage um.
var rowHeight = 12 * scale * millimeterScale;
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<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Fehltage",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale * millimeterScale;
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
// 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.
var dateColumnX = 2 * millimeterScale; var dateColumnWidth = 38 * millimeterScale;
var extentColumnX = 44 * millimeterScale; var extentColumnWidth = 66 * millimeterScale;
var statusColumnX = 114 * millimeterScale; var statusColumnWidth = 54 * millimeterScale;
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
dateColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
extentColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
statusColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
y += rowHeight;
if (rows.Count == 0)
{
commands.Add(new DrawStringEx(2 * millimeterScale, y + 2 * scale * millimeterScale, rowHeight,
contentWidth - 4 * millimeterScale, "Keine Fehltage im gewählten Zeitraum",
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280", Italic: true));
y += rowHeight + 3 * scale * millimeterScale;
}
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 * millimeterScale,
rowHeight - scale * millimeterScale, dateColumnWidth,
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
Color: "#374151"));
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, extentColumnWidth,
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, statusColumnWidth,
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
Bold: row.IsUnexcused));
y += rowHeight;
}
}
return new DrawingValue(commands, y);
}
}
/// <summary>Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines
/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus.</summary>
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, float contentWidth = 170,
float millimeterScale = 1)
{
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, contentWidth, millimeterScale);
}
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
float millimeterScale = 1)
{
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), contentWidth, millimeterScale);
}
}