CI / build-and-test (push) Waiting to run
Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten. StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei Bedarf auf weitere Seiten fließt statt abzuschneiden). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
304 lines
17 KiB
C#
304 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, float, DrawingValue>? _attendanceCalendarFactory;
|
|
private readonly Func<AttendanceCalendarOptions, float, 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, float, DrawingValue>? attendanceCalendarFactory = null,
|
|
Func<AttendanceCalendarOptions, float, 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,
|
|
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity) : null,
|
|
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions,
|
|
absenceDayListBox.Width, absenceDayListBox.MillimeterScale,
|
|
absenceDayListBox.IsFixed ? absenceDayListBox.Height : float.PositiveInfinity) : 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, float.PositiveInfinity, 1, false);
|
|
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),
|
|
};
|
|
}
|