10 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 77fe9b1c79 fix: Dokumentationsdialog wächst bei Elternbrief über den Bildschirm
CI / build-and-test (push) Waiting to run
SizeToContent="Height" ohne Begrenzung ließ das Fenster beim Typ
"Elternbrief" (drei zusätzliche Felder) so hoch werden, dass der
Speichern-Button auf kleineren Bildschirmen (z.B. MacBook) nicht mehr
erreichbar war. ScrollViewer bekommt eine MaxHeight, ab der intern
gescrollt wird - analog zum bestehenden Muster in McpConfirmDialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:12:53 +02:00
adminandClaude Sonnet 5 92e497b54f fix: Fehlzeitenkalender Spaltenbreiten und unnötigen Seitenumbruch
CI / build-and-test (push) Waiting to run
Datum-Spalte in der Fehltage-Tabelle war für "dd.MM.yyyy" zu schmal
bemessen (an der kurzen Kopfzeile "Datum" orientiert) und wurde
abgeschnitten. RenderFlow erzwang außerdem zwischen den Ausschnitten
einer zu hohen Zeichenbox einen harten Seitenumbruch unabhängig vom
tatsächlich verbleibenden Platz auf der Seite - dadurch begann z.B.
der zweite Monat auf Seite 3, obwohl Seite 2 noch reichlich Platz
hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:42:29 +02:00
admin 861f2c76ca fix: Umbruchlogik
CI / build-and-test (push) Canceled after 0s
2026-09-16 02:21:21 +02:00
admin ef3e9dbbb6 fix: Briefe Kontaktauswahl
CI / build-and-test (push) Canceled after 0s
2026-09-16 01:49:46 +02:00
admin 03a5e0dd9c fix: Fehlzeitenkalender in Templates, Datenbezug und Layout
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:58:09 +02:00
admin 6dccf96039 fix: Fehlzeitenkalender in Templates
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:50:10 +02:00
admin 6e57bf407d feat: fehlende Variablen ergänzen
CI / build-and-test (push) Canceled after 0s
2026-09-15 23:30:22 +02:00
admin 0b5cfc5522 feat: Feldeditor für Autofelder im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-09-15 11:07:37 +02:00
admin 09cbbf1b77 feat: Fehlzeiten Tabelle für Templates 2026-09-15 10:35:55 +02:00
admin 1671796844 feat: Fehlzeiten Calender 2026-09-15 10:02:33 +02:00
29 changed files with 1322 additions and 39 deletions
@@ -2,6 +2,7 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Templating;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -71,6 +72,87 @@ public sealed class ClassTeacherViewModelsTests
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet 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<DrawStringEx>(), c => c.Text == "Ada Müller");
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U");
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), 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<DrawStringEx>().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 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<DrawStringEx>().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] [Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{ {
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating; using LehrerApp.Templating;
using Xunit; using Xunit;
@@ -54,6 +55,185 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
} }
[Fact]
public async Task 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);
await vm.SetAttendanceCalendarOptionsAsync(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));
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
AttendanceCalendarOptions? requested = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
_ => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
{ requested = options; return Task.CompletedTask; });
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
Assert.NotNull(requested);
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
Assert.Equal(2, requested.NormalizedMonthCount);
Assert.False(vm.IsRefreshingAttendanceData);
Assert.Equal("", vm.AttendanceRefreshError);
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
_ => new DrawingValue([], 0),
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
Assert.False(vm.IsRefreshingAttendanceData);
}
[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);
}
[Fact]
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var betreff = Assert.Single(vm.CustomPlaceholders);
Assert.Equal("Betreff", betreff.Name);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
betreff.TextValue = "Wichtiger Termin";
var output = Path.Combine(_directory, "MitBetreff.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Empty(vm.CustomPlaceholders);
Assert.True(vm.CanGenerate);
var output = Path.Combine(_directory, "StudentName.pdf");
Assert.True(vm.Generate(output));
}
[Fact]
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var vm = Build(StudentWithContact(null), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Equal("", vm.Anrede);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
vm.Anrede = "Sehr geehrte Familie Beispiel,";
var output = Path.Combine(_directory, "AnredeManuell.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
}
[Fact]
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
{
var student = new Student
{
FirstName = "Lena", LastName = "Beispiel",
Contacts =
[
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
],
};
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.Equal(2, vm.Contacts.Count);
Assert.Contains("Frau Beispiel", vm.AddressPreview);
Assert.Contains("Erste Str. 1", vm.AddressPreview);
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
Assert.Contains("Herr Beispiel", vm.AddressPreview);
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
}
[Fact]
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
{
var contacts = new List<LetterContactChoice>
{
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
};
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
Assert.Same(contacts[1], dialogVm.SelectedContact);
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
dialogVm.SelectedContact = contacts[0];
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
}
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) => private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([])); new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
@@ -65,8 +245,18 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
var y = 20; var y = 20;
foreach (var definition in definitions) foreach (var definition in definitions)
{ {
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT"; var element = definition.Type switch
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}"); {
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; y += 20;
} }
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>()); TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
[InlineData("Ben Schmidt", "")] [InlineData("Ben Schmidt", "")]
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) => public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b)); Assert.False(UntisNameMatching.NamesMatch(a, b));
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
[Fact]
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
{
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
}
} }
@@ -0,0 +1,40 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class _TempFlowCheck2
{
[Fact]
public void MarkerTest()
{
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
Directory.CreateDirectory(outDir);
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
};
var drawing = new DrawingValue(commands, 200);
var manifest = new TemplateManifest
{
Id = "marker", Name = "Marker",
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
};
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
var loadedLayout = new LayoutParser().Parse(layout);
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
for (var i = 0; i < pngs.Count; i++)
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
}
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
}
+1
View File
@@ -229,6 +229,7 @@ public static class AppBootstrapper
services.AddSingleton<LessonPlanTools>(); services.AddSingleton<LessonPlanTools>();
services.AddSingleton<GroupMembershipTools>(); services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>(); services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<StudentAttendanceCalendarService>();
services.AddSingleton<CompetencyTools>(); services.AddSingleton<CompetencyTools>();
services.AddSingleton<GroupTools>(); services.AddSingleton<GroupTools>();
services.AddSingleton<UntisComparisonTools>(); services.AddSingleton<UntisComparisonTools>();
+21 -1
View File
@@ -19,10 +19,30 @@ public static class LetterDialogs
App.Services.GetRequiredService<TemplateStore>(), App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(), App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(), App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>()); App.Services.GetRequiredService<IGroupRepository>(),
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>().Build(student, options),
options => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.BuildAbsenceDayList(student, options),
RefreshAttendanceDataAsync);
var dialog = new CreateLetterDialog { DataContext = vm }; var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner); var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path)) if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
} }
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
{
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return;
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
await cache.GetAbsencesAsync(className, start, end, token: token);
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
}
} }
@@ -15,27 +15,39 @@ public static class LetterPlaceholderBuilder
{ {
public static Dictionary<string, PlaceholderValue> BuildStandardValues( public static Dictionary<string, PlaceholderValue> BuildStandardValues(
Student student, Contact? contact, LearningGroup? group, DateOnly date, Student student, Contact? contact, LearningGroup? group, DateOnly date,
string letterText, string teacherName) 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 = FormatAddress(contact);
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal) return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
{ {
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date), ["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""), ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName), ["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName), ["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
["Student.Name"] = new TextValue(student.FullName),
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address), ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""), ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""), ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""), ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
[StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0),
[StudentAbsenceDayListDrawingBuilder.PlaceholderName] = absenceDays ?? new DrawingValue([], 0),
}; };
} }
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
public static string FormatAddress(Contact? contact)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
public static bool IsEmpty(PlaceholderValue value) => value switch public static bool IsEmpty(PlaceholderValue value) => value switch
{ {
TextValue x => string.IsNullOrWhiteSpace(x.Value), TextValue x => string.IsNullOrWhiteSpace(x.Value),
MultilineValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
_ => false, _ => false,
}; };
} }
@@ -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.</summary> /// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
public class LetterTemplateTools( public class LetterTemplateTools(
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer, 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).")] [Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
public List<LetterTemplateDto> ListLetterTemplates() public List<LetterTemplateDto> ListLetterTemplates()
@@ -73,7 +74,9 @@ public class LetterTemplateTools(
var group = groupId is { } gid ? groups.GetById(gid) : null; var group = groupId is { } gid ? groups.GetById(gid) : null;
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today); 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),
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1)));
foreach (var (name, raw) in extraValues ?? []) foreach (var (name, raw) in extraValues ?? [])
{ {
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name); var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
@@ -0,0 +1,251 @@
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)
{
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;
var monthGapX = 10f;
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
var cellWidth = monthWidth / 7;
var commands = new List<DrawingCommand>();
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 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 = 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);
}
}
/// <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)
{
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<DrawingCommand>();
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);
}
}
/// <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)
{
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));
}
}
@@ -18,6 +18,10 @@ public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary> /// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
public enum VorgangScoreDomain { Attendance, Lateness, Classbook } public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
/// <summary>Auswahlbereich des kompakten Monatskalenders. Ein leerer Schülername steht für die
/// bisherige Klassen-Gesamtansicht.</summary>
public sealed record ClassTeacherCalendarScope(string? StudentName, string DisplayName);
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry) string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{ {
@@ -424,6 +428,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = []; public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = []; public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = []; public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { get; } = [];
[ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex; [ObservableProperty] private int _activeTabIndex;
@@ -453,6 +458,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte. /// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
[ObservableProperty] private int _ownDocumentationFollowUpCount; [ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount; [ObservableProperty] private int _ownDocumentationCriticalCount;
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein // Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen. // 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 double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " + public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt"; $"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy", public string CompactMonthLabel
{
get
{
var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE")); System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
return SelectedCalendarScope?.StudentName is { Length: > 0 }
? $"{month} · {SelectedCalendarScope.DisplayName}"
: $"{month} · gesamte Klasse";
}
}
public Func<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
@@ -534,6 +549,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow)); partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts)); partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
partial void OnOwnDocumentationCriticalCountChanged(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 OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value) partial void OnSelectedRosterFilterChanged(int value)
{ {
@@ -548,7 +568,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
ActiveTabIndex = 0; ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); 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 // 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, holidayWeekdaysExcluded, termStart, classRegisterEvents,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row); _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; StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused); TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
@@ -724,7 +751,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private void OpenMonthlyCalendar() private void OpenMonthlyCalendar()
{ {
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
DetailsTab.StudentFilter = ""; DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1); DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
ActiveTabIndex = 2; ActiveTabIndex = 2;
DetailsTab.ShowCalendarCommand.Execute(null); DetailsTab.ShowCalendarCommand.Execute(null);
@@ -735,15 +762,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
CompactMonthDays.Clear(); CompactMonthDays.Clear();
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1), foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
absences, registerEntries, today)) absences, registerEntries, today, SelectedCalendarScope?.StudentName))
CompactMonthDays.Add(day); 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<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month, public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today) IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today,
string? studentName = null)
{ {
var first = new DateOnly(month.Year, month.Month, 1); 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 var registerRows = registerEntries
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date)) .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) .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; 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 private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
{ {
ClassTeacherCalendarEventKind.Unexcused => 0, ClassTeacherCalendarEventKind.Unexcused => 0,
@@ -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<int> MonthCounts { get; } = [1, 2, 3];
public IReadOnlyList<AttendanceCalendarSizeChoice> 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);
}
}
@@ -12,31 +12,54 @@ public partial class CreateLetterDialogViewModel : ObservableObject
private readonly Student _student; private readonly Student _student;
private readonly TemplateStore _templates; private readonly TemplateStore _templates;
private readonly ITemplateRenderer _renderer; private readonly ITemplateRenderer _renderer;
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _attendanceCalendarFactory;
private readonly Func<AttendanceCalendarOptions, DrawingValue>? _absenceDayListFactory;
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
private AttendanceCalendarOptions _attendanceCalendarOptions = new(
new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1);
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate; [ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private LetterContactChoice? _selectedContact; [ObservableProperty] private LetterContactChoice? _selectedContact;
[ObservableProperty] private LetterGroupChoice? _selectedGroup; [ObservableProperty] private LetterGroupChoice? _selectedGroup;
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
[ObservableProperty] private string _anrede = "";
[ObservableProperty] private string _letterText = ""; [ObservableProperty] private string _letterText = "";
[ObservableProperty] private string _teacherName = ""; [ObservableProperty] private string _teacherName = "";
[ObservableProperty] private string _generationError = ""; [ObservableProperty] private string _generationError = "";
[ObservableProperty] private bool _canGenerate; [ObservableProperty] private bool _canGenerate;
[ObservableProperty] private bool _usesAttendanceCalendar;
[ObservableProperty] private bool _usesAbsenceDayList;
[ObservableProperty] private bool _attendanceCalendarConfigured;
[ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße";
[ObservableProperty] private bool _isRefreshingAttendanceData;
[ObservableProperty] private string _attendanceRefreshError = "";
public string StudentName => _student.FullName; public string StudentName => _student.FullName;
public ObservableCollection<LetterTemplateChoice> Templates { get; } = []; public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<LetterContactChoice> Contacts { get; } = []; public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
public ObservableCollection<LetterGroupChoice> Groups { get; } = []; public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
public ObservableCollection<LetterGenerationIssue> Issues { get; } = []; public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
public bool HasIssues => Issues.Count > 0; public bool HasIssues => Issues.Count > 0;
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
public bool HasNoTemplates => Templates.Count == 0; public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0; public bool HasNoContacts => Contacts.Count == 0;
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
public string SuggestedFileName => SanitizeFileName( public string SuggestedFileName => SanitizeFileName(
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf"); $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
IGroupMembershipRepository memberships, IGroupRepository groups) IGroupMembershipRepository memberships, IGroupRepository groups,
Func<AttendanceCalendarOptions, DrawingValue>? attendanceCalendarFactory = null,
Func<AttendanceCalendarOptions, DrawingValue>? absenceDayListFactory = null,
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
{ {
_student = student; _templates = templates; _renderer = renderer; _student = student; _templates = templates; _renderer = renderer;
_attendanceCalendarFactory = attendanceCalendarFactory;
_absenceDayListFactory = absenceDayListFactory;
_attendanceDataRefresher = attendanceDataRefresher;
foreach (var template in templates.GetTemplates()) Templates.Add(new(template)); 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 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)) foreach (var membership in memberships.GetByStudent(student.Id))
@@ -45,10 +68,33 @@ public partial class CreateLetterDialogViewModel : ObservableObject
RefreshValidation(); RefreshValidation();
} }
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); } partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); {
OnPropertyChanged(nameof(SuggestedFileName));
UsesAttendanceCalendar = TemplateUsesPlaceholder(value,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
UsesAbsenceDayList = TemplateUsesPlaceholder(value,
StudentAbsenceDayListDrawingBuilder.PlaceholderName);
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
AttendanceCalendarConfigured = false;
ResetAttendanceCalendarOptions();
RebuildCustomPlaceholders(value);
RefreshValidation();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
Anrede = value?.Model.LetterSalutation ?? "";
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
RefreshValidation();
}
partial void OnAnredeChanged(string value) => RefreshValidation();
partial void OnSelectedGroupChanged(LetterGroupChoice? 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 OnLetterTextChanged(string value) => RefreshValidation();
partial void OnTeacherNameChanged(string value) => RefreshValidation(); partial void OnTeacherNameChanged(string value) => RefreshValidation();
@@ -89,9 +135,111 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(HasIssues)); OnPropertyChanged(nameof(HasIssues));
} }
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues( private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
{
var values = LetterPlaceholderBuilder.BuildStandardValues(
_student, SelectedContact?.Model, SelectedGroup?.Model, _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,
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
return values;
}
private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
{
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
CustomPlaceholders.Clear();
if (choice is not null)
{
try
{
var loaded = _templates.Load(choice.Model);
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
{
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
input.PropertyChanged += OnCustomPlaceholderChanged;
CustomPlaceholders.Add(input);
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
}
OnPropertyChanged(nameof(HasCustomPlaceholders));
}
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
{
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
};
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
{
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
MonthCount = options.NormalizedMonthCount };
AttendanceCalendarConfigured = true;
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
AttendanceRefreshError = "";
if (_attendanceDataRefresher is not null)
{
IsRefreshingAttendanceData = true;
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
catch (WebUntisIntegrationException ex)
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
finally { IsRefreshingAttendanceData = false; }
}
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 TemplateUsesPlaceholder(LetterTemplateChoice? choice, string placeholderName)
{
if (choice is null) return false;
try
{
var loaded = _templates.Load(choice.Model);
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)
{
return false;
}
}
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 draw && draw.Placeholder == placeholderName ||
e is FlowDrawBoxElement flow && flow.Placeholder == 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 bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
private static string SanitizeFileName(string value) private static string SanitizeFileName(string value)
@@ -105,3 +253,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; } public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; }
public sealed class LetterGenerationIssue(string message, bool isStrong) public sealed class LetterGenerationIssue(string message, bool isStrong)
{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; } { public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
public sealed partial class LetterPlaceholderInput : ObservableObject
{
public string Name { get; }
public PlaceholderType Type { get; }
public string Label => Type switch
{
PlaceholderType.Date => $"{Name} (Datum)",
PlaceholderType.Number => $"{Name} (Zahl)",
_ => Name,
};
public bool IsTextType => Type == PlaceholderType.Text;
public bool IsMultilineType => Type == PlaceholderType.Multiline;
public bool IsDateType => Type == PlaceholderType.Date;
public bool IsNumberType => Type == PlaceholderType.Number;
[ObservableProperty] private string _textValue = "";
[ObservableProperty] private DateTimeOffset? _dateValue;
[ObservableProperty] private decimal? _numberValue;
public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; }
public PlaceholderValue ToPlaceholderValue() => Type switch
{
PlaceholderType.Multiline => new MultilineValue(TextValue),
PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default),
PlaceholderType.Number => new NumberValue(NumberValue ?? 0),
_ => new TextValue(TextValue),
};
}
@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class SelectContactDialogViewModel : ObservableObject
{
[ObservableProperty] private LetterContactChoice? _selectedContact;
public IReadOnlyList<LetterContactChoice> Contacts { get; }
public string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
public SelectContactDialogViewModel(IReadOnlyList<LetterContactChoice> contacts, LetterContactChoice? current)
{
Contacts = contacts;
SelectedContact = current ?? contacts.FirstOrDefault();
}
partial void OnSelectedContactChanged(LetterContactChoice? value)
{
OnPropertyChanged(nameof(AddressPreview));
OnPropertyChanged(nameof(HasAddressPreview));
}
}
@@ -436,6 +436,11 @@
Background="Transparent" BorderThickness="0" Padding="6,2" Background="Transparent" BorderThickness="0" Padding="6,2"
Foreground="{DynamicResource AppAccentTextBrush}"/> Foreground="{DynamicResource AppAccentTextBrush}"/>
</Grid> </Grid>
<ComboBox ItemsSource="{Binding CalendarScopes}"
SelectedItem="{Binding SelectedCalendarScope, Mode=TwoWay}"
DisplayMemberBinding="{Binding DisplayName}"
HorizontalAlignment="Stretch"
AutomationProperties.Name="Schüler für Monatskalender auswählen"/>
<ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center"> <ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate> <ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
@@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Students.AttendanceCalendarConfigurationDialog"
x:DataType="vm:AttendanceCalendarConfigurationViewModel"
Title="Anwesenheitsdaten konfigurieren" Width="440" Height="360"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24,20">
<StackPanel Spacing="16">
<TextBlock Text="Anwesenheitsdaten" Classes="dialogtitle"/>
<TextBlock Text="Diese Angaben steuern Kalender und Fehlzeitenliste im Elternbrief. Der Starttag wird automatisch auf den ersten Tag des gewählten Monats gesetzt."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<StackPanel Spacing="4">
<TextBlock Text="Erster Monat" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding StartMonth, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Spacing="4">
<TextBlock Text="Anzahl Monate" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding MonthCounts}" SelectedItem="{Binding MonthCount, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Darstellungsgröße" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Sizes}" SelectedItem="{Binding SelectedSize, Mode=TwoWay}"
DisplayMemberBinding="{Binding DisplayName}" HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<TextBlock Text="Bei mehreren Monaten wächst der Zeicheninhalt nach unten. Für drei große Monate sollte die Vorlage eine ausreichend hohe DRAWBOX oder eine FLOWDRAWBOX verwenden."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Students;
public partial class AttendanceCalendarConfigurationDialog : Window
{
public AttendanceCalendarConfigurationDialog() => InitializeComponent();
private void OnApply(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -26,11 +26,21 @@
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/> Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/> <TextBlock Text="Anschrift *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}" <Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10">
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/> <Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint" <TextBlock Grid.Column="0" Text="{Binding AddressPreview}" TextWrapping="Wrap"
IsVisible="{Binding HasNoContacts}"/> VerticalAlignment="Center" IsVisible="{Binding HasAddressPreview}"/>
<TextBlock Grid.Column="0" Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
VerticalAlignment="Center" IsVisible="{Binding HasNoContacts}"/>
<Button Grid.Column="1" Content="Kontakt wechseln …" Click="OnSelectContact"
IsEnabled="{Binding !HasNoContacts}" VerticalAlignment="Center"/>
</Grid>
</Border>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Anrede *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Anrede}" PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
</StackPanel> </StackPanel>
<Grid ColumnDefinitions="*,12,*"> <Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
@@ -44,6 +54,23 @@
<CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/> <CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<Border IsVisible="{Binding UsesAttendanceAdvancedContent}" BorderBrush="{DynamicResource AppCardBorderBrush}"
BorderThickness="1" CornerRadius="6" Padding="12">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3">
<TextBlock Text="Anwesenheitsdaten im Brief" FontWeight="SemiBold"/>
<TextBlock Text="{Binding AttendanceCalendarSummary}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<Button Grid.Column="1" Content="Konfigurieren …" Click="OnConfigureAttendanceCalendar"
IsEnabled="{Binding !IsRefreshingAttendanceData}" VerticalAlignment="Center"/>
</Grid>
<TextBlock Text="Aktualisiere WebUntis-Daten für den gewählten Zeitraum …" FontSize="12"
Foreground="#6B7280" IsVisible="{Binding IsRefreshingAttendanceData}"/>
<TextBlock Text="{Binding AttendanceRefreshError}" FontSize="12" Foreground="#D97706" TextWrapping="Wrap"
IsVisible="{Binding AttendanceRefreshError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/> <TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110" <TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
@@ -54,6 +81,26 @@
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/> <TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding HasCustomPlaceholders}">
<TextBlock Text="Zusätzliche Felder der Vorlage" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CustomPlaceholders}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LetterPlaceholderInput">
<StackPanel Spacing="4" Margin="0,0,0,8">
<TextBlock Text="{Binding Label}" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding TextValue}" IsVisible="{Binding IsTextType}"/>
<TextBox Text="{Binding TextValue}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"
IsVisible="{Binding IsMultilineType}"/>
<CalendarDatePicker SelectedDate="{Binding DateValue, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch" IsVisible="{Binding IsDateType}"/>
<NumericUpDown Value="{Binding NumberValue}" FormatString="0.##" HorizontalAlignment="Stretch"
IsVisible="{Binding IsNumberType}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10" <Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}"> IsVisible="{Binding HasIssues}">
<StackPanel Spacing="5"> <StackPanel Spacing="5">
@@ -12,6 +12,8 @@ public partial class CreateLetterDialog : Window
private async void OnGenerate(object? sender, RoutedEventArgs e) private async void OnGenerate(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not CreateLetterDialogViewModel vm) return; if (DataContext is not CreateLetterDialogViewModel vm) return;
if (vm.UsesAttendanceAdvancedContent && !vm.AttendanceCalendarConfigured &&
!await ConfigureAttendanceCalendar(vm)) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{ {
Title = "PDF-Brief speichern", Title = "PDF-Brief speichern",
@@ -23,5 +25,27 @@ public partial class CreateLetterDialog : Window
Close(file.Path.LocalPath); Close(file.Path.LocalPath);
} }
private async void OnConfigureAttendanceCalendar(object? sender, RoutedEventArgs e)
{
if (DataContext is CreateLetterDialogViewModel vm) await ConfigureAttendanceCalendar(vm);
}
private async void OnSelectContact(object? sender, RoutedEventArgs e)
{
if (DataContext is not CreateLetterDialogViewModel vm) return;
var dialogVm = new SelectContactDialogViewModel(vm.Contacts, vm.SelectedContact);
var dialog = new SelectContactDialog { DataContext = dialogVm };
if (await dialog.ShowDialog<bool>(this)) vm.SelectedContact = dialogVm.SelectedContact;
}
private async Task<bool> ConfigureAttendanceCalendar(CreateLetterDialogViewModel vm)
{
var configVm = new AttendanceCalendarConfigurationViewModel(vm.GetAttendanceCalendarOptions());
var dialog = new AttendanceCalendarConfigurationDialog { DataContext = configVm };
if (!await dialog.ShowDialog<bool>(this)) return false;
await vm.SetAttendanceCalendarOptionsAsync(configVm.BuildResult());
return true;
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null); private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
} }
@@ -8,7 +8,7 @@
CanResize="True" WindowStartupLocation="CenterOwner"> CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24"> <Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0"> <ScrollViewer Grid.Row="0" MaxHeight="560">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/> <TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
@@ -0,0 +1,33 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.SelectContactDialog"
x:DataType="vm:SelectContactDialogViewModel"
Title="Kontakt wählen" Width="380" Height="440"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
<TextBlock Grid.Row="0" Text="Kontakt für Anrede und Anschrift" Classes="dialogtitle" Margin="0,0,0,12"/>
<ListBox Grid.Row="1" ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LetterContactChoice">
<TextBlock Text="{Binding Display}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="2" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6"
Padding="10" Margin="0,12,0,0" IsVisible="{Binding HasAddressPreview}">
<TextBlock Text="{Binding AddressPreview}" TextWrapping="Wrap" FontSize="12"/>
</Border>
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Students;
public partial class SelectContactDialog : Window
{
public SelectContactDialog() => InitializeComponent();
private void OnApply(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -80,6 +80,40 @@ public sealed class ProjectLifecycleTests
Assert.True(viewModel.HasNoSelectedPlaceholder); Assert.True(viewModel.HasNoSelectedPlaceholder);
} }
[Theory]
[InlineData("Anwesenheitskalender", "Student.AttendanceCalendar", "DRAWBOX", "90")]
[InlineData("Fehltage nach Datum", "Student.AbsenceDays", "FLOWDRAWBOX", "140")]
public void Sonderinhalt_AusKatalog_BereitetPlatzhalterUndElementVor(
string displayName, string placeholderName, string elementType, string height)
{
var viewModel = new DesignerViewModel();
viewModel.Placeholders.Clear();
viewModel.SelectedSpecialContent = viewModel.SpecialContents.Single(x => x.DisplayName == displayName);
viewModel.PrepareSelectedSpecialContent();
var placeholder = Assert.Single(viewModel.Placeholders);
Assert.Equal(placeholderName, placeholder.Name);
Assert.Equal(LehrerApp.Templating.PlaceholderType.Drawing, placeholder.Type);
Assert.Same(placeholder, viewModel.SelectedPlaceholder);
Assert.Equal(elementType, viewModel.NewElementType);
Assert.Equal("$" + placeholderName, viewModel.NewContent);
Assert.Equal("170", viewModel.NewWidth);
Assert.Equal(height, viewModel.NewHeight);
}
[Fact]
public void Sonderinhalt_WirdNichtDoppeltDeklariert()
{
var viewModel = new DesignerViewModel();
viewModel.SelectedSpecialContent = viewModel.SpecialContents.Single(x =>
x.PlaceholderName == "Student.AttendanceCalendar");
viewModel.PrepareSelectedSpecialContent();
Assert.Single(viewModel.Placeholders, x => x.Name == "Student.AttendanceCalendar");
}
[Fact] [Fact]
public void DoppeltePlatzhalternamen_WerdenVerstaendlichAbgelehnt() public void DoppeltePlatzhalternamen_WerdenVerstaendlichAbgelehnt()
{ {
@@ -48,12 +48,15 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private string _overlayPageUnit = "mm"; [ObservableProperty] private string _overlayPageUnit = "mm";
[ObservableProperty] private string _selectedPageTemplate = "first"; [ObservableProperty] private string _selectedPageTemplate = "first";
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage; [ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>(); public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
public IReadOnlyList<string> ElementTypes { get; } = public IReadOnlyList<string> ElementTypes { get; } =
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"]; ["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"]; public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
public IReadOnlyList<SpecialContentItem> SpecialContents { get; } = SpecialContentCatalog.Items;
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } = public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
[ [
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
@@ -61,6 +64,8 @@ public partial class DesignerViewModel : ObservableObject
new("Anrede", PlaceholderType.Text, true, "Frau Beispiel"), new("Anrede", PlaceholderType.Text, true, "Frau Beispiel"),
new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"), new("Brieftext", PlaceholderType.Multiline, true, "hiermit informieren wir Sie über einen wichtigen Termin.\n\nMit freundlichen Grüßen"),
new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"), new("LehrerName", PlaceholderType.Text, true, "M. Mustermann"),
new("Student.AttendanceCalendar", PlaceholderType.Drawing, false, ""),
new("Student.AbsenceDays", PlaceholderType.Drawing, false, ""),
]; ];
public ObservableCollection<DesignerMetadata> MetadataItems { get; } = public ObservableCollection<DesignerMetadata> MetadataItems { get; } =
[ [
@@ -76,7 +81,11 @@ public partial class DesignerViewModel : ObservableObject
public bool HasSelectedPlaceholder => SelectedPlaceholder is not null; public bool HasSelectedPlaceholder => SelectedPlaceholder is not null;
public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null; public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null;
public DesignerViewModel() => SelectedPlaceholder = Placeholders.FirstOrDefault(); public DesignerViewModel()
{
SelectedPlaceholder = Placeholders.FirstOrDefault();
SelectedSpecialContent = SpecialContents.FirstOrDefault();
}
partial void OnSelectedPlaceholderChanged(DesignerPlaceholder? value) partial void OnSelectedPlaceholderChanged(DesignerPlaceholder? value)
{ {
@@ -196,6 +205,32 @@ public partial class DesignerViewModel : ObservableObject
return placeholder; return placeholder;
} }
/// <summary>Übernimmt einen lesbar benannten LehrerApp-Sonderinhalt in das normale
/// Elementformular und legt seine Drawing-Deklaration an, falls sie noch fehlt.</summary>
public void PrepareSelectedSpecialContent()
{
if (SelectedSpecialContent is not { } special)
throw new InvalidOperationException("Bitte zuerst einen Sonderinhalt auswählen.");
var placeholder = Placeholders.FirstOrDefault(p => p.Name == special.PlaceholderName);
if (placeholder is not null && placeholder.Type != PlaceholderType.Drawing)
throw new InvalidDataException(
$"Der vorhandene Platzhalter „{special.PlaceholderName}“ hat nicht den Typ Drawing.");
if (placeholder is null)
{
placeholder = new DesignerPlaceholder(special.PlaceholderName, PlaceholderType.Drawing,
false, special.Description);
Placeholders.Add(placeholder);
}
SelectedPlaceholder = placeholder;
NewElementType = special.RecommendedElementType;
NewContent = "$" + special.PlaceholderName;
NewWidth = special.RecommendedWidth;
NewHeight = special.RecommendedHeight;
NewAttributes = "";
CanExport = false;
SetStatus($"„{special.DisplayName}“ vorbereitet. Position und Größe prüfen, dann ins Layout übernehmen.", false);
}
public void RemoveSelectedPlaceholder() public void RemoveSelectedPlaceholder()
{ {
if (SelectedPlaceholder is not { } selected) return; if (SelectedPlaceholder is not { } selected) return;
@@ -552,6 +587,8 @@ public partial class DesignerViewModel : ObservableObject
try try
{ {
var page = new LayoutParser().Parse(value); var page = new LayoutParser().Parse(value);
IsLegacyContinuationSupported = !page.UsesPageTemplates;
if (page.UsesPageTemplates) UseContinuationLayout = false;
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit; OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
var names = page.PageTemplates.Select(x => x.Name).ToList(); var names = page.PageTemplates.Select(x => x.Name).ToList();
if (names.Count == 0) names.Add("legacy"); if (names.Count == 0) names.Add("legacy");
@@ -701,6 +738,48 @@ public sealed record DesignerPreviewPage(int Number, Bitmap Image)
public string Display => $"Dokumentseite {Number}"; public string Display => $"Dokumentseite {Number}";
} }
public sealed record SpecialContentItem(string DisplayName, string PlaceholderName, string Description,
string RecommendedElementType, string RecommendedWidth, string RecommendedHeight);
public static class SpecialContentCatalog
{
public static IReadOnlyList<SpecialContentItem> Items { get; } =
[
new("Anwesenheitskalender", "Student.AttendanceCalendar",
"Monatskalender mit farbigen Anwesenheitsmarkern; Zeitraum und Größe werden beim Erstellen des Briefs gewählt.",
"DRAWBOX", "170", "90"),
new("Fehltage nach Datum", "Student.AbsenceDays",
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
"FLOWDRAWBOX", "170", "140"),
];
public static DrawingValue SampleDrawing(string placeholderName) => placeholderName switch
{
"Student.AttendanceCalendar" => new DrawingValue(
[
new DrawString(2, 2, "Anwesenheit · Erika Beispiel", 10, "#1F2937", Bold: true),
new DrawRectangle(2, 14, 22, 12, "#D1D5DB", .4f, "#FFFFFF"),
new DrawString(9, 16, "12", 7, "#374151"),
new DrawRectangle(26, 14, 22, 12, "#C62828", .4f, "#C62828"),
new DrawString(34, 16, "U", 7, "#FFFFFF", Bold: true),
new DrawRectangle(50, 14, 22, 12, "#2E7D32", .4f, "#2E7D32"),
new DrawString(58, 16, "E", 7, "#FFFFFF", Bold: true),
], 28),
"Student.AbsenceDays" => new DrawingValue(
[
new DrawString(2, 2, "Fehltage · Erika Beispiel", 10, "#1F2937", Bold: true),
new DrawRectangle(2, 14, 166, 11, "#CBD5E1", .4f, "#F3F4F6"),
new DrawString(4, 15, "Datum", 7, "#374151", Bold: true),
new DrawString(42, 15, "Umfang", 7, "#374151", Bold: true),
new DrawString(116, 15, "Status", 7, "#374151", Bold: true),
new DrawString(4, 27, "03.09.2026", 7, "#374151"),
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
], 38),
_ => DesignerPlaceholder.GenericSampleDrawing(),
};
}
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
{ {
[ObservableProperty] private int _usageCount; [ObservableProperty] private int _usageCount;
@@ -755,7 +834,7 @@ public partial class DesignerPlaceholder : ObservableObject
PlaceholderType.Image => new ImageValue([], "image/png"), PlaceholderType.Image => new ImageValue([], "image/png"),
PlaceholderType.Table => ParseTable(Sample), PlaceholderType.Table => ParseTable(Sample),
PlaceholderType.Chart => ParseChart(Sample), PlaceholderType.Chart => ParseChart(Sample),
PlaceholderType.Drawing => SampleDrawing(), PlaceholderType.Drawing => SpecialContentCatalog.SampleDrawing(Name),
_ => new TextValue(Sample), _ => new TextValue(Sample),
}; };
public static string SampleFor(PlaceholderType type) => type switch public static string SampleFor(PlaceholderType type) => type switch
@@ -770,7 +849,7 @@ public partial class DesignerPlaceholder : ObservableObject
} }
private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries) private static ChartValue ParseChart(string value) => new([new("Werte", value.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]); .Select((x, i) => { var parts = x.Split(':', 2); return new ChartPoint(parts[0], parts.Length == 2 && decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var y) ? y : i + 1); }).ToList())]);
private static DrawingValue SampleDrawing() => new DrawingValue( internal static DrawingValue GenericSampleDrawing() => new DrawingValue(
[new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"), [new DrawRectangle(0, 0, 80, 24, "#2563EB", 0.8f, "#EFF6FF"),
new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true), new DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true),
new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24); new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24);
+17 -2
View File
@@ -213,6 +213,21 @@
<ScrollViewer Padding="8"> <ScrollViewer Padding="8">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Element hinzufügen" Classes="section"/> <TextBlock Text="Element hinzufügen" Classes="section"/>
<Border BorderBrush="#93C5FD" BorderThickness="1" Background="#EFF6FF"
CornerRadius="6" Padding="12">
<StackPanel Spacing="7">
<TextBlock Text="LehrerApp-Sonderinhalte" FontWeight="SemiBold" Foreground="#1E3A8A"/>
<TextBlock Text="Kein technischer Platzhaltername nötig: Inhalt auswählen und übernehmen."
FontSize="11" Foreground="#475569" TextWrapping="Wrap"/>
<ComboBox ItemsSource="{Binding SpecialContents}"
SelectedItem="{Binding SelectedSpecialContent, Mode=TwoWay}"
DisplayMemberBinding="{Binding DisplayName}"/>
<TextBlock Text="{Binding SelectedSpecialContent.Description}" FontSize="11"
Foreground="#475569" TextWrapping="Wrap"/>
<Button Content="Für Layout vorbereiten" HorizontalAlignment="Left"
Click="OnPrepareSpecialContent"/>
</StackPanel>
</Border>
<StackPanel><TextBlock Text="Einfügen in" Classes="label"/> <StackPanel><TextBlock Text="Einfügen in" Classes="label"/>
<ComboBox ItemsSource="{Binding ElementScopes}" SelectedItem="{Binding NewElementScope}"/></StackPanel> <ComboBox ItemsSource="{Binding ElementScopes}" SelectedItem="{Binding NewElementScope}"/></StackPanel>
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
@@ -256,12 +271,12 @@
<Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/> <Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/>
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid> <Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
<TabControl Grid.Row="1" Margin="12"> <TabControl Grid.Row="1" Margin="12">
<TabItem Header="Seite 1"> <TabItem Header="Skript">
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap" <TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top" FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/> ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</TabItem> </TabItem>
<TabItem Header="Folgeseiten"> <TabItem Header="Folgeseiten (Legacy)" IsVisible="{Binding IsLegacyContinuationSupported}">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern" <CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
IsChecked="{Binding UseContinuationLayout}"/> IsChecked="{Binding UseContinuationLayout}"/>
@@ -58,6 +58,8 @@ public partial class MainWindow : Window
} }
private void OnAddElement(object? sender, RoutedEventArgs e) private void OnAddElement(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } } { try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnPrepareSpecialContent(object? sender, RoutedEventArgs e)
{ try { _viewModel.PrepareSelectedSpecialContent(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnAddPageTemplate(object? sender, RoutedEventArgs e) private void OnAddPageTemplate(object? sender, RoutedEventArgs e)
{ try { _viewModel.AddPageTemplate(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } } { try { _viewModel.AddPageTemplate(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
private void OnAddContentFlow(object? sender, RoutedEventArgs e) private void OnAddContentFlow(object? sender, RoutedEventArgs e)
@@ -108,7 +108,7 @@ public sealed class PdfImportPipeline
var blank = templatePath is null ? null : Extract(templatePath); var blank = templatePath is null ? null : Extract(templatePath);
EnsureCompatible(example, blank); EnsureCompatible(example, blank);
if (example.Pages.Count > 1) if (example.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden."); throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var candidates = FindCandidates(example, blank); var candidates = FindCandidates(example, blank);
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{ {
@@ -124,7 +124,7 @@ public sealed class PdfImportPipeline
{ {
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten."); if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
if (document.Pages.Count > 1) if (document.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden."); throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var first = document.Pages[0]; var first = document.Pages[0];
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase) var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{ {
@@ -134,6 +134,8 @@ public sealed class PdfImportPipeline
var lines = new List<string> var lines = new List<string>
{ {
$"PAGE {N(first.Width)} {N(first.Height)} pt", $"PAGE {N(first.Width)} {N(first.Height)} pt",
"#pragma format-version 3",
"#pragma page-template first",
"BG pdf-import-background.png", "BG pdf-import-background.png",
}; };
var definitions = new List<PlaceholderDefinition>(); var definitions = new List<PlaceholderDefinition>();
@@ -157,6 +159,7 @@ public sealed class PdfImportPipeline
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false)); definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
candidate.Name = name; candidate.Name = name;
} }
lines.Add("#pragma end-page-template");
var manifest = new TemplateManifest var manifest = new TemplateManifest
{ {
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert", Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
@@ -3,6 +3,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" /> <ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PdfPig" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference> <PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup> </ItemGroup>
@@ -96,6 +96,43 @@ public sealed class TemplatingTests : IDisposable
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b")); System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
} }
// Regression: RenderFlow fensterte Seiten bisher nur über einen SVG-viewBox-Y-Offset, den
// QuestPDFs Svg()-Renderer nicht respektiert - dadurch landete auf jeder Folgeseite (nahezu)
// der komplette Zeicheninhalt erneut, statt nur des jeweiligen Ausschnitts (siehe Slice() in
// QuestTemplateRenderer.DrawingElementRenderer). Prüft per echter PDF-Textextraktion, dass
// seitenspezifische Marker nur auf ihrer jeweiligen Seite auftauchen.
[Fact]
public void FlowDrawBox_ZerschneidetInhaltEchtStattIhnAufFolgeseitenZuWiederholen()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
[
new DrawStringEx(0, 0, 12, 170, "MARKERTOP", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 90, 12, 170, "MARKERNEARBOTTOM", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 105, 12, 170, "MARKERAFTERBOUNDARY", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 190, 12, 170, "MARKERBOTTOM", DrawingTextAlignment.AlignLeft, 10),
], 200);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing }));
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
var pages = document.GetPages().Select(p => p.Text).ToList();
Assert.Equal(2, pages.Count);
Assert.Contains("MARKERTOP", pages[0]);
Assert.Contains("MARKERNEARBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERAFTERBOUNDARY", pages[0]);
Assert.DoesNotContain("MARKERBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERTOP", pages[1]);
Assert.DoesNotContain("MARKERNEARBOTTOM", pages[1]);
Assert.Contains("MARKERAFTERBOUNDARY", pages[1]);
Assert.Contains("MARKERBOTTOM", pages[1]);
}
[Fact] [Fact]
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum() public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
{ {
+47 -5
View File
@@ -531,21 +531,63 @@ internal static class DrawingElementRenderer
}; };
container.Column(column => container.Column(column =>
{ {
for (var page = 0; page < pages.Count; page++) // Kein manueller PageBreak() zwischen den Ausschnitten: RenderFlow sitzt in einer
// ohnehin automatisch paginierenden Column (Flow-Dokument) bzw. füllt im Legacy-Modell
// fast die ganze Seite aus. Ein erzwungener Break hier sprang unabhängig vom
// tatsächlich verbleibenden Platz auf der aktuellen physischen Seite auf eine neue -
// z.B. begann der zweite Monat der Fehlzeitenliste auf Seite 3, obwohl auf Seite 2
// noch reichlich Platz war. QuestPDF schiebt einen Ausschnitt, der nicht mehr passt,
// ohnehin automatisch auf die nächste Seite.
foreach (var page in pages)
{ {
if (page > 0) column.Item().PageBreak(); // Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
var offset = value is DrawingValue ? page * pageHeight : 0; // kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg( column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(pages[page], width, pageHeight, offset, unit)); BuildSvg(page, width, pageHeight, 0, unit));
} }
}); });
} }
// Schneidet die Zeichenbefehle anhand ihrer Y-Koordinate in Seiten auf und verschiebt sie auf
// seitenlokale Koordinaten (Y=0 am Seitenanfang). Ein SVG-Element mit "viewBox" und
// "overflow=hidden" wird von QuestPDFs Svg()-Renderer NICHT zuverlässig geclippt - ein
// (Y-)Offset im viewBox allein reicht nicht, um pro Seite nur den jeweiligen Ausschnitt zu
// zeigen; ohne dieses echte Zerschneiden landet auf jeder Seite (nahezu) der komplette
// Zeicheninhalt erneut (Bug: doppelte/überlappende Fehlzeitenliste über mehrere Seiten).
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight) private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{ {
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight)); var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
return Enumerable.Repeat(value, pageCount).ToList(); var pages = new List<List<DrawingCommand>>();
for (var i = 0; i < pageCount; i++) pages.Add([]);
var pendingLinePage = 0;
foreach (var command in value.Commands)
{
var y = CommandY(command);
if (y is null) { pages[0].Add(command); continue; }
var page = Math.Clamp((int)(y.Value / pageHeight), 0, pageCount - 1);
// LineTo gehört inhaltlich zum vorherigen MoveTo (eine Linie) - beide müssen auf
// derselben Seite landen, sonst fehlt beim Rendern der Linie der Startpunkt.
if (command is MoveTo) pendingLinePage = page;
else if (command is LineTo) page = pendingLinePage;
pages[page].Add(ShiftY(command, page * pageHeight));
} }
return pages.Select(commands => new DrawingValue(commands, pageHeight)).ToList();
}
private static float? CommandY(DrawingCommand command) => command switch
{
DrawString s => s.Y, DrawStringEx s => s.Y, MoveTo m => m.Y, LineTo l => l.Y,
DrawRectangle r => r.Y, DrawImage i => i.Y, DrawLine ln => Math.Min(ln.Y1, ln.Y2), _ => null,
};
private static DrawingCommand ShiftY(DrawingCommand command, float dy) => command switch
{
DrawString s => s with { Y = s.Y - dy }, DrawStringEx s => s with { Y = s.Y - dy },
MoveTo m => m with { Y = m.Y - dy }, LineTo l => l with { Y = l.Y - dy },
DrawRectangle r => r with { Y = r.Y - dy }, DrawImage i => i with { Y = i.Y - dy },
DrawLine ln => ln with { Y1 = ln.Y1 - dy, Y2 = ln.Y2 - dy },
_ => command,
};
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value, internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages) float width, float height, int maxPages)