From dd92128464f529cf004e1f6062743fe20c19c048 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Wed, 7 Oct 2026 10:39:09 +0200 Subject: [PATCH] =?UTF-8?q?Klausur-Deckbl=C3=A4tter=20als=20Sammel-PDF=20a?= =?UTF-8?q?us=20Vorlagen=20vom=20Typ=20exam-cover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Dialog "Deckblätter…" bei Klausuren, Punktetabelle als Drawing mit festen Spaltenbreiten, FLOWDRAWBOX-Attribut fit=content, Stapel-Rendering mit Seitenzahl je Datensatz, Kursbezeichnung und konfigurierbares Kürzel. Co-Authored-By: Claude Opus 5.5 --- .../Services/ExamCoverSheetService.cs | 61 +++++ .../ExamCoverSheetTests.cs | 245 ++++++++++++++++++ .../LehrerApp.Desktop.Tests.csproj | 1 + LehrerApp.Desktop/AppBootstrapper.cs | 2 + .../Services/ExamCoverPlaceholderBuilder.cs | 77 ++++++ .../Services/ExamCoverSheetDialogs.cs | 33 +++ .../Services/ExamPointsTableDrawingBuilder.cs | 82 ++++++ .../Services/Mcp/Tools/LetterTemplateTools.cs | 2 +- .../Services/TeacherSettingsService.cs | 41 +++ .../Exams/ExamsOverviewViewModel.cs | 8 + .../Groups/ExamCoverSheetDialogViewModel.cs | 116 +++++++++ .../Groups/ExamGradingViewModels.cs | 17 +- .../ViewModels/Groups/GroupViewModels.cs | 12 + .../PersonalizeWorksheetDialogViewModel.cs | 2 +- .../SettingsViewModel.WorksheetTemplates.cs | 8 + .../ViewModels/Settings/SettingsViewModel.cs | 5 +- .../Students/CreateLetterDialogViewModel.cs | 2 +- .../Views/Exams/ExamsOverviewView.axaml | 2 + .../Views/Exams/ExamsOverviewView.axaml.cs | 2 + .../Views/Groups/ExamCoverSheetDialog.axaml | 59 +++++ .../Groups/ExamCoverSheetDialog.axaml.cs | 29 +++ .../Views/Groups/GroupDetailView.axaml | 3 + .../Views/Groups/GroupDetailView.axaml.cs | 1 + .../Views/Settings/SettingsView.axaml | 9 + .../ExamCoverTemplatingTests.cs | 115 ++++++++ LehrerApp.Templating/LayoutParser.cs | 3 + LehrerApp.Templating/Model.cs | 9 + LehrerApp.Templating/QuestTemplateRenderer.cs | 49 +++- LehrerApp.Templating/README.md | 44 ++++ LehrerApp.Templating/TemplateMetadataText.cs | 8 + LehrerApp.Templating/TemplatePackage.cs | 13 +- LehrerApp.Tests/ExamCoverSheetServiceTests.cs | 83 ++++++ TODO.md | 43 +++ 33 files changed, 1158 insertions(+), 28 deletions(-) create mode 100644 LehrerApp.Core/Services/ExamCoverSheetService.cs create mode 100644 LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs create mode 100644 LehrerApp.Desktop/Services/ExamCoverPlaceholderBuilder.cs create mode 100644 LehrerApp.Desktop/Services/ExamCoverSheetDialogs.cs create mode 100644 LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs create mode 100644 LehrerApp.Desktop/Services/TeacherSettingsService.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/ExamCoverSheetDialogViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/ExamCoverSheetDialog.axaml.cs create mode 100644 LehrerApp.Templating.Tests/ExamCoverTemplatingTests.cs create mode 100644 LehrerApp.Tests/ExamCoverSheetServiceTests.cs 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 @@