CI / build-and-test (push) Waiting to run
Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten. StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei Bedarf auf weitere Seiten fließt statt abzuschneiden). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
302 lines
18 KiB
C#
302 lines
18 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, float contentHeight = float.PositiveInfinity)
|
|
{
|
|
var requestedScale = options.Size switch
|
|
{
|
|
AttendanceCalendarSize.Small => .7f,
|
|
AttendanceCalendarSize.Large => 1f,
|
|
_ => .85f,
|
|
};
|
|
// contentWidth/contentHeight/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/unendlich) greifen nur, wenn die
|
|
// Vorlage nicht ermittelt werden kann (z.B. Vorschau ohne Kontext).
|
|
var first = options.NormalizedStartMonth;
|
|
var monthCount = options.NormalizedMonthCount;
|
|
|
|
// Wochenanzahl je Monat vorab ermitteln, unabhängig von "Größe" - nötig, um VOR dem
|
|
// eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster braucht. Eine DRAWBOX bricht
|
|
// anders als FLOWDRAWBOX nicht automatisch auf Folgeseiten um (siehe
|
|
// DrawingElementRenderer.RenderFixed/Slice): Inhalt, der contentHeight überschreitet, wird
|
|
// von QuestTemplateRenderer am unteren Rand stillschweigend abgeschnitten. "Größe" wird
|
|
// deshalb nötigenfalls automatisch verkleinert, statt die von der Vorlage vorgegebene
|
|
// Boxhöhe zu verletzen.
|
|
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;
|
|
maxWeeks = Math.Max(maxWeeks, (int)Math.Ceiling((offset + days.Count) / 7d));
|
|
}
|
|
// Muss mit der Höhenformel am Ende dieser Methode übereinstimmen (dort als "y" berechnet):
|
|
// Titel+Name+Abstand (26) + Monats-/Wochentagskopf (20) + maxWeeks Wochenzeilen (10 je Woche)
|
|
// + Legende/Nachlauf (9), alles mit scale*millimeterScale skaliert, plus der einmalige,
|
|
// größenunabhängige monthGapX-Abstand (6*millimeterScale).
|
|
float RequiredHeight(float s) => s * millimeterScale * (55 + 10 * maxWeeks) + 6f * millimeterScale;
|
|
var scale = requestedScale;
|
|
if (float.IsFinite(contentHeight) && RequiredHeight(requestedScale) > contentHeight)
|
|
{
|
|
var perScaleUnit = RequiredHeight(1) - RequiredHeight(0);
|
|
scale = perScaleUnit > 0
|
|
? Math.Clamp((contentHeight - RequiredHeight(0)) / perScaleUnit, .35f, requestedScale)
|
|
: requestedScale;
|
|
}
|
|
|
|
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 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;
|
|
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 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, float contentHeight = float.PositiveInfinity)
|
|
{
|
|
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,
|
|
contentHeight);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|