using CommunityToolkit.Mvvm.ComponentModel; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; using LehrerApp.Templating; using System.Collections.ObjectModel; namespace LehrerApp.Desktop.ViewModels.Students; public partial class CreateLetterDialogViewModel : ObservableObject { private readonly Student _student; private readonly TemplateStore _templates; private readonly ITemplateRenderer _renderer; private readonly Func? _attendanceCalendarFactory; private readonly Func? _absenceDayListFactory; private AttendanceCalendarOptions _attendanceCalendarOptions = new( new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1), 1); [ObservableProperty] private LetterTemplateChoice? _selectedTemplate; [ObservableProperty] private LetterContactChoice? _selectedContact; [ObservableProperty] private LetterGroupChoice? _selectedGroup; [ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now; [ObservableProperty] private string _letterText = ""; [ObservableProperty] private string _teacherName = ""; [ObservableProperty] private string _generationError = ""; [ObservableProperty] private bool _canGenerate; [ObservableProperty] private bool _usesAttendanceCalendar; [ObservableProperty] private bool _usesAbsenceDayList; [ObservableProperty] private bool _attendanceCalendarConfigured; [ObservableProperty] private string _attendanceCalendarSummary = "1 Monat · Standardgröße"; public string StudentName => _student.FullName; public ObservableCollection Templates { get; } = []; public ObservableCollection Contacts { get; } = []; public ObservableCollection Groups { get; } = []; public ObservableCollection Issues { get; } = []; public ObservableCollection CustomPlaceholders { get; } = []; public bool HasIssues => Issues.Count > 0; public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0; public bool HasNoTemplates => Templates.Count == 0; public bool HasNoContacts => Contacts.Count == 0; public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList; public string SuggestedFileName => SanitizeFileName( $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf"); public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, IGroupMembershipRepository memberships, IGroupRepository groups, Func? attendanceCalendarFactory = null, Func? absenceDayListFactory = null) { _student = student; _templates = templates; _renderer = renderer; _attendanceCalendarFactory = attendanceCalendarFactory; _absenceDayListFactory = absenceDayListFactory; foreach (var template in templates.GetTemplates()) Templates.Add(new(template)); foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact)); foreach (var membership in memberships.GetByStudent(student.Id)) if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group)); SelectedTemplate = Templates.FirstOrDefault(); SelectedContact = Contacts.FirstOrDefault(); SelectedGroup = Groups.FirstOrDefault(); RefreshValidation(); } partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); UsesAttendanceCalendar = TemplateUsesPlaceholder(value, StudentAttendanceCalendarDrawingBuilder.PlaceholderName); UsesAbsenceDayList = TemplateUsesPlaceholder(value, StudentAbsenceDayListDrawingBuilder.PlaceholderName); OnPropertyChanged(nameof(UsesAttendanceAdvancedContent)); AttendanceCalendarConfigured = false; ResetAttendanceCalendarOptions(); RebuildCustomPlaceholders(value); RefreshValidation(); } partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation(); partial void OnLetterDateChanged(DateTimeOffset? value) { if (!AttendanceCalendarConfigured) ResetAttendanceCalendarOptions(); RefreshValidation(); } partial void OnLetterTextChanged(string value) => RefreshValidation(); partial void OnTeacherNameChanged(string value) => RefreshValidation(); public bool Generate(string outputPath) { RefreshValidation(); if (!CanGenerate || SelectedTemplate is null) return false; try { var pdf = _renderer.RenderToPdf(_templates.Load(SelectedTemplate.Model), new LetterDataProvider(BuildValues())); var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); File.WriteAllBytes(outputPath, pdf); GenerationError = ""; return true; } catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException) { GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}"; return false; } } private void RefreshValidation() { Issues.Clear(); GenerationError = ""; if (SelectedTemplate is null) Issues.Add(new("Bitte zuerst in den Einstellungen ein .lavorlage-Paket importieren.", true)); if (SelectedContact is null) Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true)); if (LetterDate is null) Issues.Add(new("Bitte ein Briefdatum auswählen.", true)); if (SelectedTemplate is not null) { try { var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues(); var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type)); foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error)); foreach (var required in loaded.Manifest.Placeholders.Where(x => !x.IsConstant && x.Required && values.TryGetValue(x.Name, out var value) && IsEmpty(value))) Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true)); } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { Issues.Add(new($"Vorlage ist ungültig: {ex.Message}", true)); } } CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null && Issues.Count == 0; OnPropertyChanged(nameof(HasIssues)); } private IReadOnlyDictionary BuildValues() { var values = LetterPlaceholderBuilder.BuildStandardValues( _student, SelectedContact?.Model, SelectedGroup?.Model, DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName, UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null, UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null); foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue(); return values; } private void RebuildCustomPlaceholders(LetterTemplateChoice? choice) { foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged; CustomPlaceholders.Clear(); if (choice is not null) { try { var loaded = _templates.Load(choice.Model); foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant && !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number)) { var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type); input.PropertyChanged += OnCustomPlaceholderChanged; CustomPlaceholders.Add(input); } } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { } } OnPropertyChanged(nameof(HasCustomPlaceholders)); } private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation(); private static readonly HashSet StandardPlaceholderNames = new(StringComparer.Ordinal) { "Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName", "Student.FirstName", "Student.LastName", "Contact.Name", "Contact.Address", "Contact.Street", "Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear", StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName, }; public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions; public void SetAttendanceCalendarOptions(AttendanceCalendarOptions options) { _attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth, MonthCount = options.NormalizedMonthCount }; AttendanceCalendarConfigured = true; AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions); RefreshValidation(); } private void ResetAttendanceCalendarOptions() { var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime); _attendanceCalendarOptions = new AttendanceCalendarOptions(new(date.Year, date.Month, 1), 1); AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions); } private bool TemplateUsesPlaceholder(LetterTemplateChoice? choice, string placeholderName) { if (choice is null) return false; try { var loaded = _templates.Load(choice.Model); return UsesPlaceholder(loaded.Layout, placeholderName) || (loaded.ContinuationLayout is not null && UsesPlaceholder(loaded.ContinuationLayout, placeholderName)); } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return false; } } private static bool UsesPlaceholder(TemplateLayout layout, string placeholderName) => layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements)) .Concat(layout.ContentFlows.SelectMany(f => f.Elements)) .Any(e => e is DrawBoxElement draw && draw.Placeholder == placeholderName || e is FlowDrawBoxElement flow && flow.Placeholder == placeholderName); private static string FormatAttendanceCalendarSummary(AttendanceCalendarOptions options) { var month = options.NormalizedStartMonth.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); var size = options.Size switch { AttendanceCalendarSize.Small => "Klein", AttendanceCalendarSize.Large => "Groß", _ => "Standard", }; return $"Ab {month} · {options.NormalizedMonthCount} Monat{(options.NormalizedMonthCount == 1 ? "" : "e")} · {size}"; } private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value); private static string SanitizeFileName(string value) { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; } } internal sealed class LetterDataProvider(IReadOnlyDictionary values) : ITemplateDataProvider { public IReadOnlyDictionary GetValues() => values; } public sealed class LetterTemplateChoice(InstalledTemplate model) { public InstalledTemplate Model { get; } = model; public string Name => Model.Name; } public sealed class LetterContactChoice(Contact model) { public Contact Model { get; } = model; public string Display => string.IsNullOrWhiteSpace(Model.Relation) ? Model.Name : $"{Model.Name} · {Model.Relation}"; } public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; } public sealed class LetterGenerationIssue(string message, bool isStrong) { public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; } public sealed partial class LetterPlaceholderInput : ObservableObject { public string Name { get; } public PlaceholderType Type { get; } public string Label => Type switch { PlaceholderType.Date => $"{Name} (Datum)", PlaceholderType.Number => $"{Name} (Zahl)", _ => Name, }; public bool IsTextType => Type == PlaceholderType.Text; public bool IsMultilineType => Type == PlaceholderType.Multiline; public bool IsDateType => Type == PlaceholderType.Date; public bool IsNumberType => Type == PlaceholderType.Number; [ObservableProperty] private string _textValue = ""; [ObservableProperty] private DateTimeOffset? _dateValue; [ObservableProperty] private decimal? _numberValue; public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; } public PlaceholderValue ToPlaceholderValue() => Type switch { PlaceholderType.Multiline => new MultilineValue(TextValue), PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default), PlaceholderType.Number => new NumberValue(NumberValue ?? 0), _ => new TextValue(TextValue), }; }