CI / build-and-test (push) Waiting to run
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170) aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in "pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template) blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer - sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF. CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template (LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
302 lines
17 KiB
C#
302 lines
17 KiB
C#
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<AttendanceCalendarOptions, float, float, DrawingValue>? _attendanceCalendarFactory;
|
|
private readonly Func<AttendanceCalendarOptions, float, float, DrawingValue>? _absenceDayListFactory;
|
|
private readonly Func<AttendanceCalendarOptions, CancellationToken, Task>? _attendanceDataRefresher;
|
|
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 _anrede = "";
|
|
[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";
|
|
[ObservableProperty] private bool _isRefreshingAttendanceData;
|
|
[ObservableProperty] private string _attendanceRefreshError = "";
|
|
|
|
public string StudentName => _student.FullName;
|
|
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
|
|
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
|
|
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
|
|
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
|
public ObservableCollection<LetterPlaceholderInput> 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 string AddressPreview => LetterPlaceholderBuilder.FormatAddress(SelectedContact?.Model);
|
|
public bool HasAddressPreview => !string.IsNullOrWhiteSpace(AddressPreview);
|
|
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<AttendanceCalendarOptions, float, float, DrawingValue>? attendanceCalendarFactory = null,
|
|
Func<AttendanceCalendarOptions, float, float, DrawingValue>? absenceDayListFactory = null,
|
|
Func<AttendanceCalendarOptions, CancellationToken, Task>? attendanceDataRefresher = null)
|
|
{
|
|
_student = student; _templates = templates; _renderer = renderer;
|
|
_attendanceCalendarFactory = attendanceCalendarFactory;
|
|
_absenceDayListFactory = absenceDayListFactory;
|
|
_attendanceDataRefresher = attendanceDataRefresher;
|
|
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)
|
|
{
|
|
Anrede = value?.Model.LetterSalutation ?? "";
|
|
OnPropertyChanged(nameof(AddressPreview));
|
|
OnPropertyChanged(nameof(HasAddressPreview));
|
|
RefreshValidation();
|
|
}
|
|
partial void OnAnredeChanged(string 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<string, PlaceholderValue> BuildValues()
|
|
{
|
|
var calendarBox = DeclaredDrawingBox(StudentAttendanceCalendarDrawingBuilder.PlaceholderName);
|
|
var absenceDayListBox = DeclaredDrawingBox(StudentAbsenceDayListDrawingBuilder.PlaceholderName);
|
|
var values = LetterPlaceholderBuilder.BuildStandardValues(
|
|
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
|
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
|
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions,
|
|
calendarBox.Width, calendarBox.MillimeterScale) : null,
|
|
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions,
|
|
absenceDayListBox.Width, absenceDayListBox.MillimeterScale) : null);
|
|
values["Anrede"] = new TextValue(Anrede); values["Letter.Salutation"] = new TextValue(Anrede);
|
|
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
|
|
return values;
|
|
}
|
|
|
|
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe der aktuell gewählten Vorlage für diesen
|
|
/// Platzhalter, damit der Kalender/die Fehltagesliste die real verfügbare Fläche ausfüllen statt
|
|
/// eine feste Breite zu raten (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Fällt auf
|
|
/// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.</summary>
|
|
private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
|
|
{
|
|
var fallback = new LetterPlaceholderBuilder.DeclaredDrawingBox(170, 1);
|
|
if (SelectedTemplate is null) return fallback;
|
|
try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; }
|
|
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; }
|
|
}
|
|
|
|
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<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
|
|
{
|
|
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
|
|
"Student.FirstName", "Student.LastName", "Student.Name", "Contact.Name", "Contact.Address", "Contact.Street",
|
|
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
|
|
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
|
|
};
|
|
|
|
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
|
|
|
|
public async Task SetAttendanceCalendarOptionsAsync(AttendanceCalendarOptions options, CancellationToken token = default)
|
|
{
|
|
_attendanceCalendarOptions = options with { StartMonth = options.NormalizedStartMonth,
|
|
MonthCount = options.NormalizedMonthCount };
|
|
AttendanceCalendarConfigured = true;
|
|
AttendanceCalendarSummary = FormatAttendanceCalendarSummary(_attendanceCalendarOptions);
|
|
AttendanceRefreshError = "";
|
|
if (_attendanceDataRefresher is not null)
|
|
{
|
|
IsRefreshingAttendanceData = true;
|
|
try { await _attendanceDataRefresher(_attendanceCalendarOptions, token); }
|
|
catch (WebUntisIntegrationException ex)
|
|
{ AttendanceRefreshError = $"WebUntis-Daten konnten nicht aktualisiert werden: {ex.Message}"; }
|
|
finally { IsRefreshingAttendanceData = false; }
|
|
}
|
|
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<string, PlaceholderValue> values) : ITemplateDataProvider
|
|
{ public IReadOnlyDictionary<string, PlaceholderValue> 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),
|
|
};
|
|
}
|