diff --git a/LehrerApp.Core/Services/ExamCoverSheetService.cs b/LehrerApp.Core/Services/ExamCoverSheetService.cs
new file mode 100644
index 0000000..936d9cf
--- /dev/null
+++ b/LehrerApp.Core/Services/ExamCoverSheetService.cs
@@ -0,0 +1,61 @@
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Core.Services;
+
+/// Eine Spalte der Punktetabelle auf dem Klausur-Deckblatt. Bonusaufgaben tragen keine
+/// Nummer; ist null, solange nichts eingetragen werden soll.
+public sealed record ExamCoverTaskColumn(string Number, double MaxPoints, bool IsBonus, double? AchievedPoints);
+
+public static class ExamCoverSheetService
+{
+ ///
+ /// Spalten für genau die Fassung, die der Schüler geschrieben hat. Nummeriert wird innerhalb
+ /// dieser Fassung (wie auf dem gedruckten Aufgabenblatt), Bonusaufgaben zählen nicht mit.
+ /// folgt wie der Reihenfolge aller
+ /// Aufgaben, nicht nur der dieser Fassung.
+ ///
+ public static List Columns(IReadOnlyList orderedTasks, ExamVariant variant,
+ IReadOnlyList? points)
+ {
+ var columns = new List();
+ var number = 0;
+ for (var index = 0; index < orderedTasks.Count; index++)
+ {
+ var task = orderedTasks[index];
+ if (!ExamScoringService.AppliesTo(task, variant)) continue;
+ double? achieved = points is null ? null : index < points.Count ? points[index] : 0;
+ columns.Add(new(task.IsBonus ? "" : (++number).ToString(), task.MaxPoints, task.IsBonus, achieved));
+ }
+ return columns;
+ }
+
+ /// Ein Ergebnis gilt als korrigiert, sobald Punkte oder eine Note eingetragen sind.
+ public static bool IsCorrected(ExamResult? result) =>
+ result is { Absent: false } && (result.Points.Count > 0 || !string.IsNullOrWhiteSpace(result.Grade));
+}
+
+/// Wer eine Arbeit mitschreibt — eine Regel für Punkteeingabe und Deckblätter.
+public static class ExamParticipantService
+{
+ /// Bei einer Nachschreibarbeit: Schüler, die die erste Arbeit bereits mit Note
+ /// geschrieben haben. Sonst leer.
+ public static HashSet WroteOriginal(Exam exam, Func> resultsByExam)
+ {
+ var wroteOriginal = new HashSet();
+ if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
+ foreach (var r in resultsByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
+ wroteOriginal.Add(r.StudentId);
+ return wroteOriginal;
+ }
+
+ public static bool Includes(Exam exam, GroupMembership? membership, bool hasResult, bool wroteOriginal)
+ {
+ if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) return false;
+ // Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten
+ // weiterhin für die ganze Gruppe.
+ if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false;
+ // Nachschreibarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf —
+ // außer es gibt hier bereits ein Ergebnis (nichts verstecken, was schon eingetragen wurde).
+ return hasResult || !wroteOriginal;
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
new file mode 100644
index 0000000..1b485ce
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
@@ -0,0 +1,245 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
+using LehrerApp.Desktop.ViewModels.Groups;
+using LehrerApp.Templating;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class ExamCoverSheetTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-cover-tests-{Guid.NewGuid():N}");
+ private readonly LearningGroup _group = new() { Name = "9 Ch G", SchoolYear = "2025/26", GradeLevel = 9 };
+ private readonly Student _anna = new() { FirstName = "Anna", LastName = "Adler" };
+ private readonly Student _ben = new() { FirstName = "Ben", LastName = "Bauer" };
+ private readonly Exam _exam;
+ private readonly FakeResults _results = new();
+
+ private const string Layout = """
+ PAGE 210 297 mm
+ #pragma format-version 3
+ #pragma page-template first
+ TEXT 20 20 $Student.Name size=12
+ #pragma flow-slot body x=20 y=40 w=170 h=230
+ #pragma end-page-template
+ #pragma content-flow body
+ TEXT $Exam.Title size=14
+ FLOWDRAWBOX $Exam.PointsTable w=170 h=120 fit=content gap=10
+ TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" size=11 gap=8
+ TEXT "Note: $Result.Grade" size=14 gap=4
+ #pragma end-content-flow
+ """;
+
+ public ExamCoverSheetTests()
+ {
+ Directory.CreateDirectory(_directory);
+ _exam = new Exam
+ {
+ GroupId = _group.Id, Title = "Atombau und PSE", Date = new DateOnly(2025, 12, 18), ExamNumber = 1,
+ Tasks =
+ [
+ new() { Nr = 1, MaxPoints = 5 }, new() { Nr = 2, MaxPoints = 12 },
+ new() { Nr = 3, MaxPoints = 3, IsBonus = true },
+ ],
+ GradingKey = [new() { Grade = "1", MinPercent = 90 }, new() { Grade = "6", MinPercent = 0 }],
+ };
+ }
+
+ [Fact]
+ public void PointsTable_BrichtBeiFesterSpaltenbreiteInWeitereBloeckeUm()
+ {
+ var eight = Enumerable.Range(1, 8).Select(n => new ExamCoverTaskColumn(n.ToString(), n, false, null)).ToList();
+
+ var oneBlock = ExamPointsTableDrawingBuilder.Build(eight.Take(6).ToList(), 170);
+ var twoBlocks = ExamPointsTableDrawingBuilder.Build(eight, 170);
+
+ Assert.Equal(6, ExamPointsTableDrawingBuilder.ColumnsPerBlock(170));
+ Assert.True(twoBlocks.ContentHeight > 2 * oneBlock.ContentHeight);
+ // Spalten bleiben gleich breit - der zweite Block verkleinert nichts.
+ Assert.Equal(Widths(oneBlock), Widths(twoBlocks));
+ Assert.All(twoBlocks.Commands.OfType(), r => Assert.True(r.X + r.Width <= 170));
+ }
+
+ [Fact]
+ public void PointsTable_Bonusaufgabe_OhneNummerMitPlusVorDerPunktzahl()
+ {
+ var drawing = ExamPointsTableDrawingBuilder.Build(
+ [
+ new("1", 5, false, 4.5), new("", 3, true, 1),
+ ]);
+
+ var texts = drawing.Commands.OfType().Select(x => x.Text).ToList();
+ Assert.Equal(["Aufgabe", "Max. Punktzahl", "Punktzahl", "Nr. 1", "5", "4,5", "+3", "1"], texts);
+ }
+
+ [Fact]
+ public void PointsTable_VorlageInPunkt_SkaliertDenMillimeterEntwurf()
+ {
+ List columns = [new("1", 5, false, null)];
+ var scale = 72f / 25.4f;
+
+ var millimeters = ExamPointsTableDrawingBuilder.Build(columns, 170);
+ var points = ExamPointsTableDrawingBuilder.Build(columns, 170 * scale, scale);
+
+ Assert.Equal(millimeters.ContentHeight * scale, points.ContentHeight, 2);
+ }
+
+ [Fact]
+ public void Dialog_ZeigtNurDeckblattVorlagen_AusBeidenBibliotheken()
+ {
+ var letters = Store(); var worksheets = Store();
+ Import(letters, "brief", "Brief", null);
+ Import(letters, "deckblatt-a", "Deckblatt A", TemplateReportTypes.ExamCover);
+ Import(worksheets, "deckblatt-b", "Deckblatt B", TemplateReportTypes.ExamCover);
+
+ var vm = Build([letters, worksheets]);
+
+ Assert.Equal(["Deckblatt A", "Deckblatt B"], vm.Templates.Select(x => x.Name));
+ }
+
+ [Fact]
+ public void Dialog_VorDerKorrektur_BlankoVorausgewaehlt_NachBeginnAusgefuellt()
+ {
+ Assert.False(Build([Store()]).FillResults);
+
+ _results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4, 10, 0], TotalPoints = 14 });
+
+ Assert.True(Build([Store()]).FillResults);
+ }
+
+ [Fact]
+ public void Dialog_AbwesenderSchueler_IstStandardmaessigAbgewaehlt()
+ {
+ _results.Add(new ExamResult { ExamId = _exam.Id, StudentId = _ben.Id, Absent = true });
+
+ var vm = Build([Store()]);
+
+ Assert.True(vm.Students.Single(s => s.Model.Id == _anna.Id).IsIncluded);
+ Assert.False(vm.Students.Single(s => s.Model.Id == _ben.Id).IsIncluded);
+ }
+
+ [Fact]
+ public void Generate_SchreibtEinSammelPdfMitEinerSeiteJeSchueler()
+ {
+ _results.Add(new ExamResult
+ { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
+ var store = Store();
+ Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
+ var vm = Build([store]);
+ var output = Path.Combine(_directory, "deckblaetter.pdf");
+
+ Assert.True(vm.Generate(output));
+
+ using var document = UglyToad.PdfPig.PdfDocument.Open(output);
+ Assert.Equal(2, document.NumberOfPages);
+ var anna = Normalize(document.GetPage(1).Text);
+ var ben = Normalize(document.GetPage(2).Text);
+ Assert.Contains(Normalize(_anna.FullName), anna);
+ Assert.Contains(Normalize(_ben.FullName), ben);
+ Assert.Contains("AtombauundPSE", anna);
+ // Bonusaufgabe zählt nicht zur erreichbaren Punktzahl; erreichte Bonuspunkte schon.
+ Assert.Contains("Gesamtpunkte:17,5von17Punkte", anna);
+ Assert.Contains("Note:1", anna);
+ Assert.Contains("+3", anna);
+ // Ben ist noch nicht korrigiert: Linien zum Ausfüllen statt Punkten und Note.
+ Assert.Contains($"Gesamtpunkte:{ExamCoverPlaceholderBuilder.BlankPoints}von17Punkte", ben);
+ Assert.Contains("1 davon ohne Punkte und Note", vm.StatusMessage);
+ }
+
+ [Fact]
+ public void Generate_OhnePunkteEintragen_BleibtAuchBeiKorrigiertenBlanko()
+ {
+ _results.Add(new ExamResult
+ { ExamId = _exam.Id, StudentId = _anna.Id, Points = [4.5, 12, 1], TotalPoints = 17.5 });
+ var store = Store();
+ Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
+ var vm = Build([store]);
+ vm.FillResults = false;
+ var output = Path.Combine(_directory, "blanko.pdf");
+
+ Assert.True(vm.Generate(output));
+
+ using var document = UglyToad.PdfPig.PdfDocument.Open(output);
+ Assert.DoesNotContain("17,5", Normalize(document.GetPage(1).Text));
+ }
+
+ [Fact]
+ public void Generate_ZuVieleAufgabenFuerEineSeite_WarntImStatus()
+ {
+ _exam.Tasks = Enumerable.Range(1, 80).Select(n => new ExamTask { Nr = n, MaxPoints = 1 }).ToList();
+ var store = Store();
+ Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
+ var vm = Build([store]);
+
+ Assert.True(vm.Generate(Path.Combine(_directory, "lang.pdf")));
+
+ Assert.Contains("passen nicht auf eine Seite", vm.StatusMessage);
+ }
+
+ [Theory]
+ [InlineData(GroupType.Course, Niveau.E, "E-Kurs")]
+ [InlineData(GroupType.Course, Niveau.G, "G-Kurs")]
+ [InlineData(GroupType.Course, Niveau.Foerder, "")]
+ [InlineData(GroupType.Course, null, "")]
+ [InlineData(GroupType.Class, Niveau.G, "")]
+ public void CourseLabel_NurImKursMitEOderGNiveau(GroupType type, Niveau? niveau, string expected) =>
+ Assert.Equal(expected, ExamCoverPlaceholderBuilder.CourseLabel(new LearningGroup { Type = type }, niveau));
+
+ [Fact]
+ public void Platzhalter_KursAusKlausurOderMitgliedschaft_KuerzelAusDemDialog()
+ {
+ _group.Type = GroupType.Course;
+ var store = Store();
+ Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
+ var template = store.Load(store.GetTemplates().Single());
+
+ Dictionary Values(Niveau? studentNiveau) => ExamCoverPlaceholderBuilder.Build(
+ _exam, _group, "Chemie", _anna, null, false, new GradingService(), template, new DateOnly(2026, 1, 1),
+ studentNiveau, " MON HED ");
+
+ Assert.Equal(new TextValue("E-Kurs"), Values(Niveau.E)["Exam.Course"]);
+ Assert.Equal(new TextValue("MON HED"), Values(null)["Teacher.Abbreviation"]);
+ _exam.Niveau = Niveau.G;
+ Assert.Equal(new TextValue("G-Kurs"), Values(Niveau.E)["Exam.Course"]);
+ }
+
+ [Fact]
+ public void TeacherSettings_SpeichertDasKuerzel()
+ {
+ new TeacherSettingsService(_directory).SaveAbbreviation(" HED ");
+
+ Assert.Equal("HED", new TeacherSettingsService(_directory).LoadAbbreviation());
+ }
+
+ private static string Normalize(string text) => text.Replace(" ", "");
+ private static List Widths(DrawingValue drawing) =>
+ drawing.Commands.OfType().Select(r => r.Width).Distinct().Order().ToList();
+
+ private ExamCoverSheetDialogViewModel Build(IReadOnlyList stores) =>
+ new(_exam, _group, "Chemie", new FakeStudents([_anna, _ben]), new FakeMemberships([]), _results,
+ new GradingService(), stores, new QuestTemplateRenderer());
+
+ private TemplateStore Store() => new(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
+
+ private void Import(TemplateStore store, string id, string name, string? reportType)
+ {
+ var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
+ var manifest = new TemplateManifest
+ {
+ Id = id, Name = name,
+ Placeholders =
+ [
+ new("Student.Name", PlaceholderType.Text), new("Exam.Title", PlaceholderType.Text),
+ new("Exam.MaxPoints", PlaceholderType.Text), new("Result.Points", PlaceholderType.Text),
+ new("Result.Grade", PlaceholderType.Text),
+ new(ExamPointsTableDrawingBuilder.PlaceholderName, PlaceholderType.Drawing),
+ ],
+ };
+ if (reportType is not null) manifest.Metadata[TemplateMetadataKeys.ReportType] = reportType;
+ TemplatePackage.Create(source, manifest, Layout, new Dictionary());
+ store.Import(source);
+ }
+
+ public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
+}
diff --git a/LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj b/LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj
index 5ed7169..e23e0ab 100644
--- a/LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj
+++ b/LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj
@@ -9,6 +9,7 @@
+
all
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index b0cee5a..58c4081 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -122,6 +122,7 @@ public static class AppBootstrapper
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton(_ => new TemplateStore(appData));
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
@@ -211,6 +212,7 @@ public static class AppBootstrapper
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new ExamGradingSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
+ services.AddSingleton(_ => new TeacherSettingsService(appData));
services.AddSingleton();
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
diff --git a/LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs
new file mode 100644
index 0000000..a0f12ce
--- /dev/null
+++ b/LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs
@@ -0,0 +1,77 @@
+using System.Globalization;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.ViewModels.Groups;
+using LehrerApp.Templating;
+
+namespace LehrerApp.Desktop.Services;
+
+/// Platzhalterwerte für Klausur-Deckblätter (Vorlagen mit
+/// report-type=exam-cover): die Standardwerte der Elternbriefe (Student.*, Group.Name, …)
+/// plus Exam.*, Result.* und die Punktetabelle. Alle Punktwerte gelten für die Fassung, die der
+/// Schüler geschrieben hat.
+public static class ExamCoverPlaceholderBuilder
+{
+ /// Stehen statt Punkten bzw. Note auf dem Blatt, solange nichts eingetragen wird —
+ /// als Linie zum Ausfüllen von Hand.
+ public const string BlankPoints = "_________", BlankGrade = "_____";
+
+ private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
+
+ /// Ergebnis des Schülers oder null. Punkte und Note werden nur
+ /// eingetragen, wenn gesetzt und das Ergebnis korrigiert ist.
+ /// Niveau des Schülers in dieser Lerngruppe, falls die Arbeit
+ /// selbst keinem Niveau zugeordnet ist.
+ public static Dictionary Build(Exam exam, LearningGroup group, string subjectName,
+ Student student, ExamResult? result, bool fillResults, GradingService grading, LoadedTemplate template,
+ DateOnly today, Niveau? studentNiveau = null, string teacherAbbreviation = "")
+ {
+ var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
+ var variant = result?.Variant ?? ExamVariant.A;
+ var maximum = ExamScoringService.Maximum(tasks, variant);
+ var filled = fillResults && ExamCoverSheetService.IsCorrected(result);
+
+ var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, group, today, "", "");
+ values["Group.GradeLevel"] = new TextValue(group.GradeLevel > 0 ? group.GradeLevel.ToString() : "");
+ values["Subject.Name"] = new TextValue(subjectName);
+ values["Exam.Title"] = new TextValue(exam.Title);
+ values["Exam.Date"] = new DateValue(exam.Date);
+ values["Exam.Number"] = new TextValue(exam.ExamNumber?.ToString() ?? "");
+ values["Exam.Kind"] = new TextValue(exam.Kind.Label());
+ values["Exam.Niveau"] = new TextValue(exam.Niveau.HasValue ? NiveauDisplay.ToName(exam.Niveau) : "");
+ values["Exam.Course"] = new TextValue(CourseLabel(group, exam.Niveau ?? studentNiveau));
+ values["Teacher.Abbreviation"] = new TextValue(teacherAbbreviation.Trim());
+ values["Exam.Variant"] = new TextValue(tasks.Any(t => t.Variant is not null) ? variant.ToString() : "");
+ values["Exam.MaxPoints"] = new TextValue(Format(maximum));
+ values["Exam.ApprovalDate"] = new TextValue(Format(exam.ApprovalGrantedAt));
+ values["Exam.AnnouncedDate"] = new TextValue(Format(exam.AnnouncedAt));
+ values["Exam.ReturnDate"] = new TextValue(Format(exam.ReturnedAt));
+ values["Result.Points"] = new TextValue(filled ? Format(result!.TotalPoints) : BlankPoints);
+ values["Result.Percent"] = new TextValue(filled && maximum > 0
+ ? (result!.TotalPoints / maximum).ToString("0 %", German) : "");
+ values["Result.Grade"] = new TextValue(filled
+ ? result!.Grade ?? grading.CalculateGrade(result.TotalPoints, maximum, exam.GradingKey) : BlankGrade);
+
+ // Ohne Box in der Vorlage gibt es nichts zu zeichnen (die Tabelle ist optional).
+ values[ExamPointsTableDrawingBuilder.PlaceholderName] =
+ LetterPlaceholderBuilder.FindDeclaredDrawingBox(template, ExamPointsTableDrawingBuilder.PlaceholderName) is { } box
+ ? ExamPointsTableDrawingBuilder.Build(
+ ExamCoverSheetService.Columns(tasks, variant, filled ? result!.Points : null),
+ box.Width, box.MillimeterScale)
+ : new DrawingValue([], 0);
+ return values;
+ }
+
+ /// "E-Kurs" / "G-Kurs" für die äußere Differenzierung; leer im Klassenverband und
+ /// ohne E-/G-Zuordnung.
+ public static string CourseLabel(LearningGroup group, Niveau? niveau) =>
+ group.Type == GroupType.Class ? "" : niveau switch
+ {
+ Niveau.E => "E-Kurs",
+ Niveau.G => "G-Kurs",
+ _ => "",
+ };
+
+ private static string Format(double value) => value.ToString("0.##", German);
+ private static string Format(DateOnly? date) => date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
+}
diff --git a/LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs b/LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs
new file mode 100644
index 0000000..8b1cddf
--- /dev/null
+++ b/LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs
@@ -0,0 +1,33 @@
+using Avalonia.Controls;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.ViewModels.Groups;
+using LehrerApp.Desktop.Views.Groups;
+using LehrerApp.Templating;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LehrerApp.Desktop.Services;
+
+/// Öffnet den Deckblatt-Dialog zu einer Klausur — ein Einstieg für die Klausuren-
+/// Hauptseite und den Klausuren-Tab der Lerngruppe.
+public static class ExamCoverSheetDialogs
+{
+ public static async Task ShowAsync(Control source, Exam exam)
+ {
+ var group = App.Services.GetRequiredService().GetById(exam.GroupId);
+ if (group is null || TopLevel.GetTopLevel(source) is not Window owner) return;
+ var subject = group.SubjectId is { } subjectId
+ ? App.Services.GetRequiredService().GetById(subjectId)?.Name ?? ""
+ : "";
+ var vm = new ExamCoverSheetDialogViewModel(exam, group, subject,
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ [App.Services.GetRequiredService(), App.Services.GetRequiredService().Store],
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService().LoadAbbreviation());
+ await new ExamCoverSheetDialog { DataContext = vm }.ShowDialog(owner);
+ }
+}
diff --git a/LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs b/LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs
new file mode 100644
index 0000000..b665cad
--- /dev/null
+++ b/LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs
@@ -0,0 +1,82 @@
+using System.Globalization;
+using LehrerApp.Core.Services;
+using LehrerApp.Templating;
+
+namespace LehrerApp.Desktop.Services;
+
+/// Zeichnet die Punktetabelle des Klausur-Deckblatts (Aufgabe / Max. Punktzahl /
+/// Punktzahl). Die Spalten haben eine feste Breite: passen nicht alle Aufgaben nebeneinander in die
+/// Box der Vorlage, folgt darunter ein weiterer Tabellenblock, statt alles zu verkleinern. Gedacht
+/// für eine FLOWDRAWBOX … fit=content, damit nachfolgende Elemente mitwandern.
+public static class ExamPointsTableDrawingBuilder
+{
+ public const string PlaceholderName = "Exam.PointsTable";
+
+ private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
+
+ // Referenzentwurf in Millimetern, abgemessen an der Schulvorlage.
+ private const float LabelWidth = 38f, ColumnWidth = 20.5f;
+ private const float HeaderHeight = 5.5f, MaximumHeight = 8.5f, PointsHeight = 11.5f;
+ private const float BlockGap = 4f, Padding = 1.5f, Stroke = 0.2f, HeavyStroke = 0.45f;
+ private const float FontSize = 10f;
+
+ /// Breite der deklarierten Box in der Maßeinheit der Vorlage.
+ /// 1 mm in der Maßeinheit der Vorlage (siehe
+ /// ).
+ public static DrawingValue Build(IReadOnlyList columns, float contentWidth = 170,
+ float millimeterScale = 1)
+ {
+ if (columns.Count == 0) return new DrawingValue([], 0);
+ var k = millimeterScale;
+ var perBlock = ColumnsPerBlock(contentWidth, millimeterScale);
+ // Halbe Strichstärke Abstand zum Rand, sonst schneidet die Box die äußeren Linien an.
+ var origin = HeavyStroke / 2 * k;
+ var blockHeight = (HeaderHeight + MaximumHeight + PointsHeight) * k;
+ var commands = new List();
+ var y = origin;
+ for (var start = 0; start < columns.Count; start += perBlock)
+ {
+ if (start > 0) y += BlockGap * k;
+ var block = columns.Skip(start).Take(perBlock).ToList();
+ var maximumY = y + HeaderHeight * k;
+ var pointsY = maximumY + MaximumHeight * k;
+
+ Cell(commands, origin, y, LabelWidth * k, HeaderHeight * k, "Aufgabe", DrawingTextAlignment.AlignRight, false, k);
+ Cell(commands, origin, maximumY, LabelWidth * k, MaximumHeight * k, "Max. Punktzahl", DrawingTextAlignment.AlignRight, false, k);
+ Cell(commands, origin, pointsY, LabelWidth * k, PointsHeight * k, "Punktzahl", DrawingTextAlignment.AlignRight, true, k);
+ for (var index = 0; index < block.Count; index++)
+ {
+ var column = block[index];
+ var x = origin + (LabelWidth + index * ColumnWidth) * k;
+ Cell(commands, x, y, ColumnWidth * k, HeaderHeight * k,
+ column.IsBonus ? "" : $"Nr. {column.Number}", DrawingTextAlignment.AlignCenter, false, k);
+ Cell(commands, x, maximumY, ColumnWidth * k, MaximumHeight * k,
+ (column.IsBonus ? "+" : "") + Format(column.MaxPoints), DrawingTextAlignment.AlignCenter, false, k);
+ Cell(commands, x, pointsY, ColumnWidth * k, PointsHeight * k,
+ column.AchievedPoints is { } achieved ? Format(achieved) : "", DrawingTextAlignment.AlignCenter, false, k);
+ }
+ commands.Add(new DrawLine(origin, pointsY, origin + (LabelWidth + block.Count * ColumnWidth) * k, pointsY,
+ StrokeWidth: HeavyStroke * k));
+ y += blockHeight;
+ }
+ return new DrawingValue(commands, y + origin);
+ }
+
+ /// Wie viele Aufgaben bei fester Spaltenbreite nebeneinander in die Box passen.
+ public static int ColumnsPerBlock(float contentWidth, float millimeterScale = 1) =>
+ Math.Max(1, (int)Math.Floor((contentWidth / millimeterScale - HeavyStroke - LabelWidth + 0.01f) / ColumnWidth));
+
+ private static void Cell(List commands, float x, float y, float width, float height,
+ string text, DrawingTextAlignment alignment, bool bold, float k)
+ {
+ commands.Add(new DrawRectangle(x, y, width, height, StrokeWidth: Stroke * k));
+ if (text.Length == 0) return;
+ // DrawStringEx setzt die Grundlinie auf Y + Schriftgröße; so sitzt die Zeile optisch mittig.
+ var fontHeight = FontSize * UnitConverter.Points(1, "pt") / UnitConverter.Points(1, "mm") * k;
+ var textY = y + height / 2 - fontHeight * 0.64f;
+ commands.Add(new DrawStringEx(x + Padding * k, textY, y + height - textY, width - 2 * Padding * k, text,
+ alignment, FontSize, Bold: bold));
+ }
+
+ private static string Format(double value) => value.ToString("0.##", German);
+}
diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs
index 8c3dd46..01731d8 100644
--- a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs
+++ b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs
@@ -26,7 +26,7 @@ public class LetterTemplateTools(
public List ListLetterTemplates()
{
var result = new List();
- foreach (var installed in templates.GetTemplates())
+ foreach (var installed in templates.GetTemplates().Where(x => !x.IsExamCover))
{
try
{
diff --git a/LehrerApp.Desktop/Services/TeacherSettingsService.cs b/LehrerApp.Desktop/Services/TeacherSettingsService.cs
new file mode 100644
index 0000000..eae50f1
--- /dev/null
+++ b/LehrerApp.Desktop/Services/TeacherSettingsService.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+
+namespace LehrerApp.Desktop.Services;
+
+internal sealed class TeacherSettingsConfig
+{
+ public string Abbreviation { get; set; } = "";
+}
+
+/// Angaben zur Lehrkraft, die auf Formularen erscheinen (bisher nur das Kürzel fürs
+/// Klausur-Deckblatt) — aus der Konfiguration statt fest in der Vorlage, damit eine weitergegebene
+/// Vorlage nicht das Kürzel ihres Erstellers trägt.
+public sealed class TeacherSettingsService
+{
+ private readonly string _configPath;
+
+ public TeacherSettingsService(string appDataPath) =>
+ _configPath = Path.Combine(appDataPath, "teachersettings.json");
+
+ public string LoadAbbreviation()
+ {
+ try
+ {
+ if (File.Exists(_configPath))
+ return JsonSerializer.Deserialize(File.ReadAllText(_configPath))
+ ?.Abbreviation ?? "";
+ }
+ catch { /* beschädigte Konfiguration -> leer */ }
+ return "";
+ }
+
+ public void SaveAbbreviation(string abbreviation)
+ {
+ try
+ {
+ File.WriteAllText(_configPath,
+ JsonSerializer.Serialize(new TeacherSettingsConfig { Abbreviation = abbreviation.Trim() }));
+ }
+ catch { /* Einstellung ist Komfort — Fehler beim Schreiben nicht an die UI durchreichen */ }
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs
index 9a94e3f..0b7016b 100644
--- a/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs
@@ -43,6 +43,7 @@ public partial class ExamsOverviewViewModel : ObservableObject
public Func? OnGradeExam { get; set; }
public Func? OnEvaluateExam { get; set; }
public Func? OnRevealExam { get; set; }
+ public Func? OnCoverSheets { get; set; }
public Action? OnNavigateToGroup { get; set; }
public Action? OnNavigateToGroups { get; set; }
public bool HasNoExams => Rows.Count == 0;
@@ -177,6 +178,13 @@ public partial class ExamsOverviewViewModel : ObservableObject
await OnRevealExam(SelectedRow.Exam);
}
+ [RelayCommand]
+ private async Task CoverSheetsSelected()
+ {
+ if (SelectedRow is null || OnCoverSheets is null) return;
+ await OnCoverSheets(SelectedRow.Exam);
+ }
+
[RelayCommand]
private void GoToGroup()
{
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs
new file mode 100644
index 0000000..00f7e2b
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs
@@ -0,0 +1,116 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
+using LehrerApp.Desktop.ViewModels.Students;
+using LehrerApp.Templating;
+using System.Collections.ObjectModel;
+
+namespace LehrerApp.Desktop.ViewModels.Groups;
+
+/// Erzeugt zu einer Klausur einen Satz individueller Deckblätter als ein Sammel-PDF.
+/// Zur Auswahl stehen nur Vorlagen mit report-type=exam-cover, egal in welcher der beiden
+/// Vorlagenbibliotheken sie importiert wurden. Mit trägt dieselbe
+/// Vorlage Punkte und Note ein oder bleibt als Blanko-Deckblatt zum Ausfüllen von Hand.
+public partial class ExamCoverSheetDialogViewModel : ObservableObject
+{
+ private readonly Exam _exam;
+ private readonly LearningGroup _group;
+ private readonly string _subjectName;
+ private readonly GradingService _grading;
+ private readonly TemplateStore _loader;
+ private readonly ITemplateBatchRenderer _renderer;
+
+ [ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
+ [ObservableProperty] private bool _fillResults;
+ [ObservableProperty] private string _statusMessage = "";
+ /// Vorbelegt aus den Einstellungen, hier nur für diesen Druck änderbar (z. B. zwei
+ /// Kürzel bei einem gemeinsam unterrichteten Kurs) — die Einstellung bleibt unberührt.
+ [ObservableProperty] private string _teacherAbbreviation;
+
+ public string ExamLabel => $"{_exam.Title} · {_group.Name} · {_exam.Date:dd.MM.yyyy}";
+ public ObservableCollection Templates { get; } = [];
+ public ObservableCollection Students { get; } = [];
+ public bool HasNoTemplates => Templates.Count == 0;
+ public string SuggestedFileName => SanitizeFileName($"Deckblätter_{_group.Name}_{_exam.Title}.pdf");
+
+ public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
+ IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
+ GradingService grading, IReadOnlyList templateStores, ITemplateBatchRenderer renderer,
+ string teacherAbbreviation = "")
+ {
+ _teacherAbbreviation = teacherAbbreviation;
+ _exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
+ _loader = templateStores[0];
+ foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
+ .DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
+ Templates.Add(new(template));
+ SelectedTemplate = Templates.FirstOrDefault();
+
+ var membershipList = memberships.GetByGroup(group.Id);
+ var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
+ var wroteOriginal = ExamParticipantService.WroteOriginal(exam, results.GetByExam);
+ foreach (var student in students.GetByGroup(group.Id).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
+ {
+ var membership = membershipList.FirstOrDefault(m => m.StudentId == student.Id);
+ existing.TryGetValue(student.Id, out var result);
+ if (!ExamParticipantService.Includes(exam, membership, result is not null, wroteOriginal.Contains(student.Id)))
+ continue;
+ Students.Add(new(student, result, membership?.Niveau));
+ }
+ // Vor der Korrektur ist das Blanko-Deckblatt der Normalfall, danach das ausgefüllte.
+ FillResults = Students.Any(s => s.IsCorrected);
+ }
+
+ /// Schreibt das Sammel-PDF und meldet den Erfolg.
+ public bool Generate(string outputPath)
+ {
+ StatusMessage = "";
+ if (SelectedTemplate is null) return false;
+ var included = Students.Where(s => s.IsIncluded).ToList();
+ if (included.Count == 0) { StatusMessage = "Kein Schüler ausgewählt."; return false; }
+ try
+ {
+ var loaded = _loader.Load(SelectedTemplate.Model);
+ var today = DateOnly.FromDateTime(DateTime.Now);
+ var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
+ ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
+ _grading, loaded, today, s.Niveau, TeacherAbbreviation))).ToList();
+ var result = _renderer.RenderBatchToPdf(loaded, data);
+ File.WriteAllBytes(outputPath, result.Pdf);
+
+ var message = $"{included.Count} Deckblätter erzeugt.";
+ var blank = FillResults ? included.Count(s => !s.IsCorrected) : 0;
+ if (blank > 0) message += $" {blank} davon ohne Punkte und Note (noch nicht korrigiert).";
+ var overflowing = result.PageCounts.Count(pages => pages > 1);
+ if (overflowing > 0)
+ message += $" Achtung: {overflowing} Deckblätter passen nicht auf eine Seite — die Punktetabelle braucht mehr Platz, als die Vorlage lässt.";
+ StatusMessage = message;
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
+ { StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
+ }
+
+ private static string SanitizeFileName(string value)
+ { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
+}
+
+public partial class ExamCoverStudentChoice : ObservableObject
+{
+ public Student Model { get; }
+ public ExamResult? Result { get; }
+ public Niveau? Niveau { get; }
+ public string FullName => Model.FullName;
+ public bool IsCorrected => ExamCoverSheetService.IsCorrected(Result);
+ public string Hint => Result is { Absent: true } ? "abwesend" : "";
+ [ObservableProperty] private bool _isIncluded;
+
+ public ExamCoverStudentChoice(Student model, ExamResult? result, Niveau? niveau = null)
+ {
+ Model = model; Result = result; Niveau = niveau;
+ // Wer gefehlt hat, bekommt kein Deckblatt — lässt sich im Dialog wieder anhaken.
+ _isIncluded = result is not { Absent: true };
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
index 474564a..e6b921e 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
@@ -54,25 +54,14 @@ public partial class ExamGradingDialogViewModel : ObservableObject
var membershipList = memberships.GetByGroup(groupId);
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
- // Nachschreibarbeit: nur Schüler, die die erste Arbeit nicht mitgeschrieben haben. Wer dort
- // schon eine Note hat, taucht nicht auf — außer es gibt hier bereits ein Ergebnis (nichts
- // verstecken, was schon eingetragen wurde).
- var wroteOriginal = new HashSet();
- if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
- foreach (var r in results.GetByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
- wroteOriginal.Add(r.StudentId);
+ var wroteOriginal = ExamParticipantService.WroteOriginal(exam, results.GetByExam);
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
- if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
-
- // Niveau-Klausur: nur Schüler mit passendem Niveau zeigen. Klausuren ohne
- // Niveau-Zuordnung gelten weiterhin für die ganze Gruppe.
- if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
-
existing.TryGetValue(s.Id, out var result);
- if (result is null && wroteOriginal.Contains(s.Id)) continue;
+ if (!ExamParticipantService.Includes(exam, membership, result is not null, wroteOriginal.Contains(s.Id)))
+ continue;
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
row.OnChanged = SaveRow;
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
index fa14c0c..c3498b8 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
@@ -235,6 +235,7 @@ public partial class GroupDetailViewModel : ObservableObject
public Func? OnGradeExam { get; set; }
public Func? OnEvaluateExam { get; set; }
public Func? OnRevealExam { get; set; }
+ public Func? OnExamCoverSheets { get; set; }
public Func>? OnConfirmReactivate { get; set; }
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
@@ -421,6 +422,15 @@ public partial class GroupDetailViewModel : ObservableObject
await OnRevealExam(exam);
}
+ [RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
+ private async Task ExamCoverSheets()
+ {
+ if (SelectedExam is null || OnExamCoverSheets is null) return;
+ var exam = _exams.GetById(SelectedExam.Id);
+ if (exam is null) return;
+ await OnExamCoverSheets(exam);
+ }
+
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
private async Task DuplicateExam()
{
@@ -496,6 +506,7 @@ public partial class GroupDetailViewModel : ObservableObject
GradeExamCommand.NotifyCanExecuteChanged();
EvaluateExamCommand.NotifyCanExecuteChanged();
RevealExamCommand.NotifyCanExecuteChanged();
+ ExamCoverSheetsCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
@@ -526,6 +537,7 @@ public partial class GroupDetailViewModel : ObservableObject
GradeExamCommand.NotifyCanExecuteChanged();
EvaluateExamCommand.NotifyCanExecuteChanged();
RevealExamCommand.NotifyCanExecuteChanged();
+ ExamCoverSheetsCommand.NotifyCanExecuteChanged();
DuplicateExamCommand.NotifyCanExecuteChanged();
DeleteExamCommand.NotifyCanExecuteChanged();
AdvanceExamStatusCommand.NotifyCanExecuteChanged();
diff --git a/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs
index b275874..7561ccf 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/PersonalizeWorksheetDialogViewModel.cs
@@ -33,7 +33,7 @@ public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
{
_group = group; _templates = templates; _renderer = renderer;
- foreach (var template in templates.Store.GetTemplates()) Templates.Add(new(template));
+ foreach (var template in templates.Store.GetTemplates().Where(x => !x.IsExamCover)) Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault();
foreach (var id in studentIds)
if (students.GetById(id) is { } student) Students.Add(new(student));
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WorksheetTemplates.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WorksheetTemplates.cs
index 9eccc36..bf3147e 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WorksheetTemplates.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.WorksheetTemplates.cs
@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Desktop.Services;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
@@ -7,6 +8,13 @@ namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
+ private readonly TeacherSettingsService? _teacherSettings;
+
+ /// Eigenes Kürzel für Formulare (Platzhalter Teacher.Abbreviation auf dem
+ /// Klausur-Deckblatt). Wird sofort gespeichert; im Deckblatt-Dialog je Druck überschreibbar.
+ [ObservableProperty] private string _teacherAbbreviation = "";
+ partial void OnTeacherAbbreviationChanged(string value) => _teacherSettings?.SaveAbbreviation(value);
+
[ObservableProperty] private string _worksheetTemplateStatus = "";
public ObservableCollection WorksheetTemplateList { get; } = [];
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
index 1b78fa0..b18ea38 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
@@ -121,8 +121,11 @@ public partial class SettingsViewModel : ObservableObject
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
- SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
+ SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null,
+ TeacherSettingsService? teacherSettings = null)
{
+ _teacherSettings = teacherSettings;
+ _teacherAbbreviation = teacherSettings?.LoadAbbreviation() ?? "";
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated;
diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
index e1406f1..c33907e 100644
--- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
@@ -60,7 +60,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
_attendanceCalendarFactory = attendanceCalendarFactory;
_absenceDayListFactory = absenceDayListFactory;
_attendanceDataRefresher = attendanceDataRefresher;
- foreach (var template in templates.GetTemplates()) Templates.Add(new(template));
+ foreach (var template in templates.GetTemplates().Where(x => !x.IsExamCover)) 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))
if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group));
diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml
index 3f2a71d..f95e89d 100644
--- a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml
+++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml
@@ -177,6 +177,8 @@
+
diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs
index c8d227e..37bdbc9 100644
--- a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
+using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups;
@@ -24,6 +25,7 @@ public partial class ExamsOverviewView : UserControl
vm.OnGradeExam = ShowGradeExamDialog;
vm.OnEvaluateExam = ShowEvaluateExamDialog;
vm.OnRevealExam = ShowRevealExamDialog;
+ vm.OnCoverSheets = exam => ExamCoverSheetDialogs.ShowAsync(this, exam);
vm.OnNavigateToGroup = groupId => App.Services.GetRequiredService()
.NavigateToGroupDetail(groupId, GroupDetailKlausurenTabIndex);
}
diff --git a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml
new file mode 100644
index 0000000..1859825
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs
new file mode 100644
index 0000000..bd74b6a
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs
@@ -0,0 +1,29 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using LehrerApp.Desktop.ViewModels.Groups;
+
+namespace LehrerApp.Desktop.Views.Groups;
+
+public partial class ExamCoverSheetDialog : Window
+{
+ public ExamCoverSheetDialog() => InitializeComponent();
+
+ private async void OnGenerate(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not ExamCoverSheetDialogViewModel vm) return;
+ var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Deckblätter speichern",
+ SuggestedFileName = vm.SuggestedFileName,
+ DefaultExtension = "pdf",
+ FileTypeChoices = [new FilePickerFileType("PDF") { Patterns = ["*.pdf"] }],
+ });
+ if (file is null) return;
+ var path = file.Path.LocalPath;
+ if (vm.Generate(path))
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
+ }
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+}
diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
index a6f063f..02a9a75 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml
@@ -120,6 +120,8 @@
+
@@ -167,6 +169,7 @@
+