Files
2026-10-22 11:24:43 +02:00

182 lines
10 KiB
C#

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;
/// <summary>Erzeugt zu einer Klausur einen Satz individueller Deckblätter als ein Sammel-PDF.
/// Zur Auswahl stehen nur Vorlagen mit <c>report-type=exam-cover</c>, egal in welcher der beiden
/// Vorlagenbibliotheken sie importiert wurden. Mit <see cref="FillResults"/> trägt dieselbe
/// Vorlage Punkte und Note ein oder bleibt als Blanko-Deckblatt zum Ausfüllen von Hand.</summary>
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<Guid, ExamResult> _originalResults;
[ObservableProperty] private ReportInterimRunChoice? _selectedInterimRun;
[ObservableProperty] private bool _usesInterimGrades;
public ObservableCollection<ReportInterimRunChoice> 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 = "";
/// <summary>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.</summary>
[ObservableProperty] private string _teacherAbbreviation;
/// <summary>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.</summary>
[ObservableProperty] private string _coverTitle;
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
[ObservableProperty] private int _progressValue;
[ObservableProperty] private int _progressMaximum = 1;
/// <summary>Alle Deckblätter sind gesetzt, das Sammel-PDF entsteht — dieser Schritt meldet
/// keinen Zwischenstand, der Balken läuft deshalb unbestimmt.</summary>
[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<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<ExamCoverStudentChoice> 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<TemplateStore> 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);
}
/// <summary>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.</summary>
public async Task<bool> 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<int>(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<InterimGrade> Grades)
{
public string Label => $"{Date:dd.MM.yyyy} · {Occasion}";
}