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; private readonly Exam? _original; private readonly Dictionary _originalResults; [ObservableProperty] private ReportInterimRunChoice? _selectedInterimRun; [ObservableProperty] private bool _usesInterimGrades; public ObservableCollection InterimRuns { get; } = []; partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { UsesInterimGrades = value is not null && DrawingBoxLocator.Find(_loader.Load(value.Model), ReportTextDrawing.InterimGrades) is not null; } [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; /// Vorbelegt mit dem internen Klausurtitel, hier nur für diesen Druck änderbar — der /// Titel der Klausur bleibt unberührt. Leer gelassen gilt wieder der interne Titel. [ObservableProperty] private string _coverTitle; [ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating; [ObservableProperty] private int _progressValue; [ObservableProperty] private int _progressMaximum = 1; /// Alle Deckblätter sind gesetzt, das Sammel-PDF entsteht — dieser Schritt meldet /// keinen Zwischenstand, der Balken läuft deshalb unbestimmt. [ObservableProperty] private bool _isMerging; [ObservableProperty] private string _progressText = ""; public string ExamTitle => _exam.Title; 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 bool CanGenerate => !HasNoTemplates && !IsGenerating; 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 = "", IExamRepository? exams = null, IInterimGradeRepository? interims = null) { _teacherAbbreviation = teacherAbbreviation; _coverTitle = exam.Title; _exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer; _loader = templateStores[0]; _original = exam.RepeatOfExamId is { } originalId ? exams?.GetById(originalId) : null; _originalResults = exam.RepeatOfExamId is { } firstId ? results.GetByExam(firstId).ToDictionary(r => r.StudentId) : []; foreach (var run in (interims?.GetByGroup(group.Id) ?? []) .GroupBy(g => (g.Date, g.Occasion)).OrderByDescending(g => g.Key.Date).ThenBy(g => g.Key.Occasion)) InterimRuns.Add(new(run.Key.Date, run.Key.Occasion, run.ToList())); // Bewusste Auswahl statt stillschweigend den neuesten Zwischenstand zu drucken. 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. Gesetzt wird im Hintergrund, damit /// der Dialog bei einem Klassensatz bedienbar bleibt und den Fortschritt zeigen kann. public async Task GenerateAsync(string outputPath) { StatusMessage = ""; if (SelectedTemplate is null || IsGenerating) 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); if (UsesInterimGrades && SelectedInterimRun is null) { StatusMessage = "Bitte einen gespeicherten Notenzwischenstand auswählen."; return false; } if (UsesInterimGrades) { var missing = included.Where(s => !SelectedInterimRun!.Grades.Any(g => g.StudentId == s.Model.Id)).ToList(); if (missing.Count > 0) { StatusMessage = "Im gewählten Zwischenstand fehlen: " + string.Join(", ", missing.Select(s => s.FullName)) + ". Bitte Schüler abwählen oder einen anderen Durchgang wählen."; return false; } } 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, CoverTitle, _original, _originalResults.GetValueOrDefault(s.Model.Id), UsesInterimGrades ? SelectedInterimRun!.Grades.Single(g => g.StudentId == s.Model.Id) : null))).ToList(); ReportProgress(0, included.Count); IsGenerating = true; var progress = new Progress(done => ReportProgress(done, included.Count)); var result = await Task.Run(() => { var rendered = _renderer.RenderBatchToPdf(loaded, data, progress); File.WriteAllBytes(outputPath, rendered.Pdf); return rendered; }); 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 or QuestPDF.Drawing.Exceptions.DocumentLayoutException) { StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; } finally { IsGenerating = false; } } private void ReportProgress(int done, int total) { ProgressMaximum = total; ProgressValue = done; IsMerging = done >= total && total > 1; ProgressText = IsMerging ? "Sammel-PDF wird zusammengefügt …" : $"{done} von {total} Deckblättern gesetzt …"; } 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 }; } } public sealed record ReportInterimRunChoice(DateOnly Date, string Occasion, IReadOnlyList Grades) { public string Label => $"{Date:dd.MM.yyyy} · {Occasion}"; }