Compare commits
10
Commits
70dc78904c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fe9b1c79 | ||
|
|
92e497b54f | ||
|
|
861f2c76ca | ||
|
|
ef3e9dbbb6 | ||
|
|
03a5e0dd9c | ||
|
|
6dccf96039 | ||
|
|
6e57bf407d | ||
|
|
0b5cfc5522 | ||
|
|
09cbbf1b77 | ||
|
|
1671796844 |
@@ -2,6 +2,7 @@ using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -71,6 +72,87 @@ public sealed class ClassTeacherViewModelsTests
|
||||
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kompaktkalender_KannAufEinzelnenSchuelerEingeschraenktWerden()
|
||||
{
|
||||
var month = new DateOnly(2026, 9, 1);
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
|
||||
"Ada Müller");
|
||||
|
||||
Assert.Equal("U", days[2].SignalCode);
|
||||
Assert.False(days[3].HasSignal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ElternbriefKalender_LiefertPortablenDrawingPlatzhalterFuerSchueler()
|
||||
{
|
||||
var month = new DateOnly(2026, 9, 1);
|
||||
var absences = new[]
|
||||
{
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
|
||||
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
|
||||
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
|
||||
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
|
||||
};
|
||||
|
||||
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", month, absences, []);
|
||||
|
||||
Assert.True(drawing.ContentHeight > 0);
|
||||
Assert.Contains(drawing.Commands.OfType<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]
|
||||
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
@@ -54,6 +55,185 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
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) =>
|
||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||
|
||||
@@ -65,8 +245,18 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
var y = 20;
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||
var element = definition.Type switch
|
||||
{
|
||||
PlaceholderType.Multiline => "TEXTBOX",
|
||||
PlaceholderType.Drawing => "DRAWBOX",
|
||||
_ => "TEXT",
|
||||
};
|
||||
lines.Add(element switch
|
||||
{
|
||||
"TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}",
|
||||
"DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}",
|
||||
_ => $"TEXT 20 {y} ${definition.Name}",
|
||||
});
|
||||
y += 20;
|
||||
}
|
||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||
|
||||
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
|
||||
[InlineData("Ben Schmidt", "")]
|
||||
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string 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; }
|
||||
}
|
||||
@@ -229,6 +229,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<LessonPlanTools>();
|
||||
services.AddSingleton<GroupMembershipTools>();
|
||||
services.AddSingleton<LetterTemplateTools>();
|
||||
services.AddSingleton<StudentAttendanceCalendarService>();
|
||||
services.AddSingleton<CompetencyTools>();
|
||||
services.AddSingleton<GroupTools>();
|
||||
services.AddSingleton<UntisComparisonTools>();
|
||||
|
||||
@@ -19,10 +19,30 @@ public static class LetterDialogs
|
||||
App.Services.GetRequiredService<TemplateStore>(),
|
||||
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||
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 path = await dialog.ShowDialog<string?>(owner);
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
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(
|
||||
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 = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var address = FormatAddress(contact);
|
||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||
{
|
||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
||||
["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.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||
[StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0),
|
||||
[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
|
||||
{
|
||||
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
|
||||
_ => 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>
|
||||
public class LetterTemplateTools(
|
||||
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
|
||||
IStudentRepository students, IGroupRepository groups)
|
||||
IStudentRepository students, IGroupRepository groups,
|
||||
StudentAttendanceCalendarService? attendanceCalendars = null)
|
||||
{
|
||||
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
|
||||
public List<LetterTemplateDto> ListLetterTemplates()
|
||||
@@ -73,7 +74,9 @@ public class LetterTemplateTools(
|
||||
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
||||
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName);
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
|
||||
teacherName, attendanceCalendars?.Build(student, date),
|
||||
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1)));
|
||||
foreach (var (name, raw) in extraValues ?? [])
|
||||
{
|
||||
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>
|
||||
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,
|
||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||
{
|
||||
@@ -424,6 +428,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
|
||||
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { get; } = [];
|
||||
|
||||
[ObservableProperty] private string? _homeroomClassName;
|
||||
[ObservableProperty] private int _activeTabIndex;
|
||||
@@ -453,6 +458,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
||||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||||
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
|
||||
|
||||
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
|
||||
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
|
||||
@@ -501,8 +507,17 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||||
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||||
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||||
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
|
||||
public string CompactMonthLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
|
||||
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||
return SelectedCalendarScope?.StudentName is { Length: > 0 }
|
||||
? $"{month} · {SelectedCalendarScope.DisplayName}"
|
||||
: $"{month} · gesamte Klasse";
|
||||
}
|
||||
}
|
||||
|
||||
public Func<Task>? OnNavigateToSettings { 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 OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||||
partial void OnSelectedCalendarScopeChanged(ClassTeacherCalendarScope? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CompactMonthLabel));
|
||||
if (CalendarScopes.Count > 0) RebuildCompactMonthFromCachedData();
|
||||
}
|
||||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||||
partial void OnSelectedRosterFilterChanged(int value)
|
||||
{
|
||||
@@ -548,7 +568,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
ActiveTabIndex = 0;
|
||||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||||
OpenExcuses.Clear(); CompactMonthDays.Clear(); OpenExcuseOverflowCount = 0;
|
||||
OpenExcuses.Clear(); CompactMonthDays.Clear(); CalendarScopes.Clear(); SelectedCalendarScope = null;
|
||||
OpenExcuseOverflowCount = 0;
|
||||
}
|
||||
|
||||
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
|
||||
@@ -678,6 +699,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
holidayWeekdaysExcluded, termStart, classRegisterEvents,
|
||||
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
||||
|
||||
CalendarScopes.Add(new ClassTeacherCalendarScope(null, "Gesamte Klasse"));
|
||||
foreach (var student in students.OrderBy(s => s.DisplayName))
|
||||
CalendarScopes.Add(new ClassTeacherCalendarScope(student.DisplayName,
|
||||
ClassTeacherOverviewViewModel.DisplayStudentName(student.DisplayName)));
|
||||
SelectedCalendarScope = CalendarScopes[0];
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||||
@@ -724,7 +751,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
private void OpenMonthlyCalendar()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
DetailsTab.StudentFilter = "";
|
||||
DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
|
||||
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
|
||||
ActiveTabIndex = 2;
|
||||
DetailsTab.ShowCalendarCommand.Execute(null);
|
||||
@@ -735,15 +762,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
{
|
||||
CompactMonthDays.Clear();
|
||||
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
|
||||
absences, registerEntries, today))
|
||||
absences, registerEntries, today, SelectedCalendarScope?.StudentName))
|
||||
CompactMonthDays.Add(day);
|
||||
}
|
||||
|
||||
private void RebuildCompactMonthFromCachedData()
|
||||
{
|
||||
if (_lastAbsences is null || _lastClassRegisterEvents is null) return;
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
BuildCompactMonth(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(_lastAbsences),
|
||||
_lastClassRegisterEvents, today);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
|
||||
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);
|
||||
if (!string.IsNullOrWhiteSpace(studentName))
|
||||
{
|
||||
absences = absences.Where(a => UntisNameMatching.NamesMatch(a.StudentName, studentName)).ToList();
|
||||
registerEntries = registerEntries.Where(e => UntisNameMatching.NamesMatch(e.StudentName, studentName)).ToList();
|
||||
}
|
||||
var registerRows = registerEntries
|
||||
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
|
||||
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
|
||||
@@ -768,6 +809,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static string DisplayStudentName(string value)
|
||||
{
|
||||
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
|
||||
}
|
||||
|
||||
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
|
||||
{
|
||||
ClassTeacherCalendarEventKind.Unexcused => 0,
|
||||
|
||||
@@ -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 TemplateStore _templates;
|
||||
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 LetterContactChoice? _selectedContact;
|
||||
[ObservableProperty] private LetterGroupChoice? _selectedGroup;
|
||||
[ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _anrede = "";
|
||||
[ObservableProperty] private string _letterText = "";
|
||||
[ObservableProperty] private string _teacherName = "";
|
||||
[ObservableProperty] private string _generationError = "";
|
||||
[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 ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
||||
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
||||
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
||||
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
||||
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
|
||||
public bool HasIssues => Issues.Count > 0;
|
||||
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
|
||||
public bool HasNoTemplates => Templates.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(
|
||||
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
|
||||
|
||||
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups)
|
||||
IGroupMembershipRepository memberships, IGroupRepository groups,
|
||||
Func<AttendanceCalendarOptions, DrawingValue>? attendanceCalendarFactory = null,
|
||||
Func<AttendanceCalendarOptions, DrawingValue>? absenceDayListFactory = null,
|
||||
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
|
||||
{
|
||||
_student = student; _templates = templates; _renderer = renderer;
|
||||
_attendanceCalendarFactory = attendanceCalendarFactory;
|
||||
_absenceDayListFactory = absenceDayListFactory;
|
||||
_attendanceDataRefresher = attendanceDataRefresher;
|
||||
foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
|
||||
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact));
|
||||
foreach (var membership in memberships.GetByStudent(student.Id))
|
||||
@@ -45,10 +68,33 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
RefreshValidation();
|
||||
}
|
||||
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
|
||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
||||
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value)
|
||||
{
|
||||
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 OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation();
|
||||
partial void OnLetterDateChanged(DateTimeOffset? value)
|
||||
{
|
||||
if (!AttendanceCalendarConfigured) ResetAttendanceCalendarOptions();
|
||||
RefreshValidation();
|
||||
}
|
||||
partial void OnLetterTextChanged(string value) => RefreshValidation();
|
||||
partial void OnTeacherNameChanged(string value) => RefreshValidation();
|
||||
|
||||
@@ -89,9 +135,111 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||
{
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(
|
||||
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName);
|
||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
||||
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
|
||||
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 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 LetterGenerationIssue(string message, bool isStrong)
|
||||
{ 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"
|
||||
Foreground="{DynamicResource AppAccentTextBrush}"/>
|
||||
</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.ItemsPanel>
|
||||
<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}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
|
||||
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
|
||||
IsVisible="{Binding HasNoContacts}"/>
|
||||
<TextBlock Text="Anschrift *" FontSize="12" Opacity="0.7"/>
|
||||
<Border BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" CornerRadius="6" Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding AddressPreview}" TextWrapping="Wrap"
|
||||
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>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
@@ -44,6 +54,23 @@
|
||||
<CalendarDatePicker SelectedDate="{Binding LetterDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</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">
|
||||
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
|
||||
@@ -54,6 +81,26 @@
|
||||
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
|
||||
</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"
|
||||
IsVisible="{Binding HasIssues}">
|
||||
<StackPanel Spacing="5">
|
||||
|
||||
@@ -12,6 +12,8 @@ public partial class CreateLetterDialog : Window
|
||||
private async void OnGenerate(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not CreateLetterDialogViewModel vm) return;
|
||||
if (vm.UsesAttendanceAdvancedContent && !vm.AttendanceCalendarConfigured &&
|
||||
!await ConfigureAttendanceCalendar(vm)) return;
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "PDF-Brief speichern",
|
||||
@@ -23,5 +25,27 @@ public partial class CreateLetterDialog : Window
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<ScrollViewer Grid.Row="0">
|
||||
<ScrollViewer Grid.Row="0" MaxHeight="560">
|
||||
<StackPanel Spacing="12">
|
||||
<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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void DoppeltePlatzhalternamen_WerdenVerstaendlichAbgelehnt()
|
||||
{
|
||||
|
||||
@@ -48,12 +48,15 @@ public partial class DesignerViewModel : ObservableObject
|
||||
[ObservableProperty] private string _overlayPageUnit = "mm";
|
||||
[ObservableProperty] private string _selectedPageTemplate = "first";
|
||||
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
|
||||
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
|
||||
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
|
||||
|
||||
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
||||
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
||||
public IReadOnlyList<string> ElementTypes { get; } =
|
||||
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
|
||||
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
|
||||
public IReadOnlyList<SpecialContentItem> SpecialContents { get; } = SpecialContentCatalog.Items;
|
||||
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
|
||||
[
|
||||
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("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("Student.AttendanceCalendar", PlaceholderType.Drawing, false, ""),
|
||||
new("Student.AbsenceDays", PlaceholderType.Drawing, false, ""),
|
||||
];
|
||||
public ObservableCollection<DesignerMetadata> MetadataItems { get; } =
|
||||
[
|
||||
@@ -76,7 +81,11 @@ public partial class DesignerViewModel : ObservableObject
|
||||
public bool HasSelectedPlaceholder => SelectedPlaceholder is not 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)
|
||||
{
|
||||
@@ -196,6 +205,32 @@ public partial class DesignerViewModel : ObservableObject
|
||||
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()
|
||||
{
|
||||
if (SelectedPlaceholder is not { } selected) return;
|
||||
@@ -552,6 +587,8 @@ public partial class DesignerViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
var page = new LayoutParser().Parse(value);
|
||||
IsLegacyContinuationSupported = !page.UsesPageTemplates;
|
||||
if (page.UsesPageTemplates) UseContinuationLayout = false;
|
||||
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
|
||||
var names = page.PageTemplates.Select(x => x.Name).ToList();
|
||||
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 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
|
||||
{
|
||||
[ObservableProperty] private int _usageCount;
|
||||
@@ -755,7 +834,7 @@ public partial class DesignerPlaceholder : ObservableObject
|
||||
PlaceholderType.Image => new ImageValue([], "image/png"),
|
||||
PlaceholderType.Table => ParseTable(Sample),
|
||||
PlaceholderType.Chart => ParseChart(Sample),
|
||||
PlaceholderType.Drawing => SampleDrawing(),
|
||||
PlaceholderType.Drawing => SpecialContentCatalog.SampleDrawing(Name),
|
||||
_ => new TextValue(Sample),
|
||||
};
|
||||
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)
|
||||
.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 DrawString(4, 4, "Dynamischer Inhalt", 10, "#1E3A8A", Bold: true),
|
||||
new MoveTo(4, 19), new LineTo(76, 19, "#93C5FD", 0.6f)], 24);
|
||||
|
||||
@@ -213,6 +213,21 @@
|
||||
<ScrollViewer Padding="8">
|
||||
<StackPanel Spacing="12">
|
||||
<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"/>
|
||||
<ComboBox ItemsSource="{Binding ElementScopes}" SelectedItem="{Binding NewElementScope}"/></StackPanel>
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
@@ -256,12 +271,12 @@
|
||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/>
|
||||
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
|
||||
<TabControl Grid.Row="1" Margin="12">
|
||||
<TabItem Header="Seite 1">
|
||||
<TabItem Header="Skript">
|
||||
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||
</TabItem>
|
||||
<TabItem Header="Folgeseiten">
|
||||
<TabItem Header="Folgeseiten (Legacy)" IsVisible="{Binding IsLegacyContinuationSupported}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
|
||||
IsChecked="{Binding UseContinuationLayout}"/>
|
||||
|
||||
@@ -58,6 +58,8 @@ public partial class MainWindow : Window
|
||||
}
|
||||
private void OnAddElement(object? sender, RoutedEventArgs e)
|
||||
{ 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)
|
||||
{ try { _viewModel.AddPageTemplate(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnAddContentFlow(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class PdfImportPipeline
|
||||
var blank = templatePath is null ? null : Extract(templatePath);
|
||||
EnsureCompatible(example, blank);
|
||||
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);
|
||||
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 > 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 assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -134,6 +134,8 @@ public sealed class PdfImportPipeline
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"PAGE {N(first.Width)} {N(first.Height)} pt",
|
||||
"#pragma format-version 3",
|
||||
"#pragma page-template first",
|
||||
"BG pdf-import-background.png",
|
||||
};
|
||||
var definitions = new List<PlaceholderDefinition>();
|
||||
@@ -157,6 +159,7 @@ public sealed class PdfImportPipeline
|
||||
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
|
||||
candidate.Name = name;
|
||||
}
|
||||
lines.Add("#pragma end-page-template");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="PdfPig" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -96,6 +96,43 @@ public sealed class TemplatingTests : IDisposable
|
||||
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]
|
||||
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
|
||||
{
|
||||
|
||||
@@ -531,21 +531,63 @@ internal static class DrawingElementRenderer
|
||||
};
|
||||
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();
|
||||
var offset = value is DrawingValue ? page * pageHeight : 0;
|
||||
// Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
|
||||
// kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
|
||||
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)
|
||||
{
|
||||
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,
|
||||
float width, float height, int maxPages)
|
||||
|
||||
Reference in New Issue
Block a user