QuestPDF-Vorlagensystem und Designer hinzufügen
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-30 15:37:22 +02:00
parent a088823b30
commit 2f2761caf8
40 changed files with 2177 additions and 738 deletions
+4 -1
View File
@@ -16,6 +16,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop;
@@ -117,6 +118,9 @@ public static class AppBootstrapper
services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>();
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
@@ -193,7 +197,6 @@ public static class AppBootstrapper
services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData));
services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ──────
@@ -10,6 +10,7 @@
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
@@ -1,37 +1,19 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
[ObservableProperty] private string _letterTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
LetterTemplateService.SupportedPlaceholders;
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
private void LoadLetterTemplates()
{
LetterTemplateList.Clear();
foreach (var template in _letterTemplates.GetTemplates())
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
foreach (var template in _letterTemplates.GetTemplates()) LetterTemplateList.Add(CreateItem(template));
}
public void ImportLetterTemplate(string path)
@@ -40,75 +22,59 @@ public partial class SettingsViewModel
try
{
var template = _letterTemplates.Import(path);
var validation = _letterTemplates.Validate(template);
LoadLetterTemplates();
LetterTemplateStatus = validation.Issues.Count == 0
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
{
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
LetterTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
}
[RelayCommand]
private void ValidateLetterTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
var index = LetterTemplateList.IndexOf(item);
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
if (index >= 0) LetterTemplateList[index] = refreshed;
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
? $"„{item.Name}“ ist ohne Auffälligkeiten."
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
try
{
var refreshed = CreateItem(item.Model);
var index = LetterTemplateList.IndexOf(item);
if (index >= 0) LetterTemplateList[index] = refreshed;
LetterTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ LetterTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
}
[RelayCommand]
private void DeleteLetterTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
_letterTemplates.Delete(item.Id);
LetterTemplateList.Remove(item);
LetterTemplateStatus = "Vorlage gelöscht.";
_letterTemplates.Delete(item.Id); LetterTemplateList.Remove(item); LetterTemplateStatus = "Vorlage gelöscht.";
}
private LetterTemplateListItem CreateItem(InstalledTemplate template)
{
var loaded = _letterTemplates.Load(template);
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count,
loaded.Manifest.Placeholders.Count(x => x.Required));
}
}
public sealed class LetterTemplateListItem
public sealed class LetterTemplateListItem(InstalledTemplate model, int schemaVersion, int fieldCount, int requiredCount)
{
public LetterTemplateInfo Model { get; }
public TemplateValidationResult Validation { get; }
public Guid Id => Model.Id;
public InstalledTemplate Model { get; } = model;
public string Id => Model.Id;
public string Name => Model.Name;
public string OriginalFileName => Model.OriginalFileName;
public bool HasIssues => Validation.Issues.Count > 0;
public bool HasNoIssues => !HasIssues;
public string ValidationSummary => HasNoIssues
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
public ObservableCollection<LetterTemplateIssueItem> Issues { get; }
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
{
Model = model;
Validation = validation;
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
}
public string PackageFileName => Path.GetFileName(Model.PackagePath);
public int SchemaVersion { get; } = schemaVersion;
public bool HasIssues => false;
public bool HasNoIssues => true;
public string ValidationSummary => $"Schema {SchemaVersion} · {fieldCount} Felder ({requiredCount} Pflicht)";
public ObservableCollection<LetterTemplateIssueItem> Issues { get; } = [];
}
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
public sealed class LetterTemplateIssueItem(ValidationIssue issue)
{
public string Icon => issue.Severity switch
{
TemplateIssueSeverity.Error => "⛔",
TemplateIssueSeverity.StrongWarning => "⚠",
_ => "ⓘ",
};
public string Icon => issue.Severity == ValidationSeverity.Error ? "⛔" : "ⓘ";
public string Message => issue.Message;
public string Color => issue.Severity switch
{
TemplateIssueSeverity.Error => "#DC2626",
TemplateIssueSeverity.StrongWarning => "#D97706",
_ => "#6B7280",
};
public string Color => issue.Severity == ValidationSeverity.Error ? "#DC2626" : "#6B7280";
}
@@ -13,6 +13,7 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.ViewModels.Settings;
@@ -59,7 +60,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes;
private readonly LetterTemplateService _letterTemplates;
private readonly TemplateStore _letterTemplates;
[ObservableProperty] private int _activeTabIndex;
@@ -97,7 +98,7 @@ public partial class SettingsViewModel : ObservableObject
IDocumentationRepository documentation, IStudentRepository students,
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings,
@@ -1,21 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class CreateLetterDialogViewModel : ObservableObject
{
private readonly Student _student;
private readonly LetterTemplateService _templates;
private readonly TemplateStore _templates;
private readonly ITemplateRenderer _renderer;
[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;
@@ -28,155 +30,92 @@ public partial class CreateLetterDialogViewModel : ObservableObject
public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0;
public string SuggestedFileName => SanitizeFileName(
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.docx");
$"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf");
public CreateLetterDialogViewModel(Student student, LetterTemplateService templates,
public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer,
IGroupMembershipRepository memberships, IGroupRepository groups)
{
_student = student;
_templates = templates;
foreach (var template in templates.GetTemplates())
Templates.Add(new LetterTemplateChoice(template));
foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name))
Contacts.Add(new LetterContactChoice(contact));
_student = student; _templates = templates; _renderer = renderer;
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))
{
var group = groups.GetById(membership.GroupId);
if (group is not null) Groups.Add(new LetterGroupChoice(group));
}
SelectedTemplate = Templates.FirstOrDefault();
SelectedContact = Contacts.FirstOrDefault();
SelectedGroup = Groups.FirstOrDefault();
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));
RefreshValidation();
}
partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); }
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation();
partial void OnLetterDateChanged(DateTimeOffset? value) => 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;
GenerationError = "";
RefreshValidation(); if (!CanGenerate || SelectedTemplate is null) return false;
try
{
_templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues());
return true;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
{
GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}";
return false;
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 eine DOCX-Briefvorlage 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));
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)
{
var validation = _templates.Validate(SelectedTemplate.Model);
foreach (var issue in validation.Issues)
Issues.Add(new(issue.Message, issue.Severity is TemplateIssueSeverity.StrongWarning or TemplateIssueSeverity.Error));
var tags = validation.Tags.ToHashSet(StringComparer.Ordinal);
var values = BuildValues();
foreach (var tag in tags.Where(t => values.TryGetValue(t, out var value) && string.IsNullOrWhiteSpace(value)))
try
{
var message = tag switch
{
"Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.",
"Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" =>
$"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.",
"Group.Name" or "SchoolYear" => "Die Vorlage verwendet Gruppendaten; bitte eine Lerngruppe auswählen.",
_ => $"Für das Vorlagenfeld „{tag}“ ist kein Wert vorhanden.",
};
Issues.Add(new(message, true));
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.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;
CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null && Issues.Count == 0;
OnPropertyChanged(nameof(HasIssues));
}
private IReadOnlyDictionary<string, string?> BuildValues()
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
{
var contact = SelectedContact?.Model;
var group = SelectedGroup?.Model;
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }
.Where(v => !string.IsNullOrWhiteSpace(v)));
var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine }
.Where(v => !string.IsNullOrWhiteSpace(v)));
var date = LetterDate is null ? null : DateOnly.FromDateTime(LetterDate.Value.LocalDateTime)
.ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE"));
return new Dictionary<string, string?>
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
{
["Student.FirstName"] = _student.FirstName,
["Student.LastName"] = _student.LastName,
["Contact.Name"] = contact?.Name,
["Contact.Address"] = address,
["Contact.Street"] = contact?.Street,
["Contact.PostalCode"] = contact?.PostalCode,
["Contact.City"] = contact?.City,
["Letter.Salutation"] = contact?.LetterSalutation,
["Group.Name"] = group?.Name,
["SchoolYear"] = group?.SchoolYear,
["CurrentDate"] = date,
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
};
}
private static bool IsEmpty(PlaceholderValue value) => value switch
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
private static string SanitizeFileName(string value)
{
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
return value;
}
}
public sealed class LetterTemplateChoice
{
public LetterTemplateInfo Model { get; }
public string Name => Model.Name;
public LetterTemplateChoice(LetterTemplateInfo model) => Model = model;
}
public sealed class LetterContactChoice
{
public Contact Model { get; }
public string Display => string.IsNullOrWhiteSpace(Model.Relation)
? Model.Name
: $"{Model.Name} · {Model.Relation}";
public LetterContactChoice(Contact model) => Model = model;
}
public sealed class LetterGroupChoice
{
public LearningGroup Model { get; }
public string Display => $"{Model.Name} · {Model.SchoolYear}";
public LetterGroupChoice(LearningGroup model) => Model = model;
{ 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 string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; }
@@ -328,15 +328,15 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
<!-- Tab: portable PDF-Briefvorlagen -->
<ContentPage Header="Briefvorlagen">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="16" MaxWidth="760">
<TextBlock Text="Word-Vorlagen bleiben vollständig in Word gestaltet. Die App befüllt Inhaltssteuerelemente anhand ihres Tags und prüft die Vorlage bereits beim Import."
<TextBlock Text="Portable .lavorlage-Pakete werden beim Import vollständig geprüft und lokal kopiert. Briefe entstehen anschließend als flache, durchsuchbare PDF-Dateien."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<Button Grid.Column="0" Content=" DOCX-Vorlage importieren" Click="OnImportLetterTemplateClick"/>
<Button Grid.Column="0" Content=" .lavorlage importieren" Click="OnImportLetterTemplateClick"/>
<TextBlock Grid.Column="1" Text="{Binding LetterTemplateStatus}" FontSize="12"
VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding LetterTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -355,7 +355,7 @@
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
<TextBlock FontSize="11" Opacity="0.55">
<Run Text="{Binding OriginalFileName}"/><Run Text=" · "/>
<Run Text="{Binding PackageFileName}"/><Run Text=" · "/>
<Run Text="{Binding ValidationSummary}"/>
</TextBlock>
</StackPanel>
@@ -391,19 +391,8 @@
</ItemsControl>
<Separator/>
<TextBlock Text="Unterstützte Tags" FontSize="15" FontWeight="SemiBold"/>
<TextBlock Text="In Word: Entwicklertools → Rich-Text- oder Nur-Text-Inhaltssteuerelement → Eigenschaften → Tag. Der Titel ist frei wählbar; ausgewertet wird der Tag."
<TextBlock Text="Vorlagen werden mit dem separaten LehrerApp Vorlagen-Designer erstellt. Designer und Hauptapp verwenden exakt dieselbe QuestPDF-Renderingbibliothek."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding SupportedLetterPlaceholders}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="services:LetterPlaceholder">
<Grid ColumnDefinitions="190,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Tag}" FontFamily="Monospace" FontSize="12"/>
<TextBlock Grid.Column="1" Text="{Binding Description}" FontSize="12" Opacity="0.7"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
@@ -263,9 +263,9 @@ public partial class SettingsView : UserControl
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Word-Briefvorlage importieren",
Title = "LehrerApp-Briefvorlage importieren",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
FileTypeFilter = [new FilePickerFileType("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] }],
});
if (files.Count > 0) vm.ImportLetterTemplate(files[0].Path.LocalPath);
}
@@ -273,8 +273,7 @@ public partial class SettingsView : UserControl
private void OnOpenLetterTemplateClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: LetterTemplateListItem item }) return;
var service = App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>();
var path = service.GetTemplatePath(item.Model);
var path = item.Model.PackagePath;
if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
@@ -32,7 +32,7 @@
<TextBlock Text="Briefanrede" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterSalutation}"
PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
<TextBlock Text="Wird unverändert in Word-Vorlagen für Letter.Salutation eingesetzt."
<TextBlock Text="Wird unverändert für den Vorlagen-Platzhalter Anrede eingesetzt."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel>
@@ -3,15 +3,15 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
x:DataType="vm:CreateLetterDialogViewModel"
Title="Word-Brief erstellen" Width="580" Height="650"
Title="PDF-Brief erstellen" Width="580" Height="760"
MinWidth="500" MinHeight="540" CanResize="True"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24,20">
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="15">
<TextBlock Text="Word-Brief erstellen" Classes="dialogtitle"/>
<TextBlock Text="PDF-Brief erstellen" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
Text="Die Vorlage wird vor der Erzeugung erneut geprüft. Das Original bleibt unverändert; gespeichert wird eine frei bearbeitbare DOCX-Kopie."/>
Text="Die portable Vorlage und alle Pflichtfelder werden vor der Erzeugung geprüft. Gespeichert wird ein flaches, durchsuchbares PDF."/>
<StackPanel Spacing="4">
<TextBlock Text="Schüler" FontSize="12" Opacity="0.7"/>
@@ -43,6 +43,15 @@
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Brieftext" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterText}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="110"
PlaceholderText="Inhalt des Briefes"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Lehrkraft / Unterschrift" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
</StackPanel>
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}">
@@ -68,7 +77,7 @@
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="DOCX speichern …" HorizontalAlignment="Stretch"
<Button Grid.Column="2" Content="PDF speichern …" HorizontalAlignment="Stretch"
IsEnabled="{Binding CanGenerate}" Click="OnGenerate"/>
</Grid>
</Grid>
@@ -14,10 +14,10 @@ public partial class CreateLetterDialog : Window
if (DataContext is not CreateLetterDialogViewModel vm) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Word-Brief speichern",
Title = "PDF-Brief speichern",
SuggestedFileName = vm.SuggestedFileName,
DefaultExtension = "docx",
FileTypeChoices = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
DefaultExtension = "pdf",
FileTypeChoices = [new FilePickerFileType("PDF-Dateien") { Patterns = ["*.pdf"] }],
});
if (file is null || !vm.Generate(file.Path.LocalPath)) return;
Close(file.Path.LocalPath);
@@ -28,7 +28,7 @@
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
<Button Content="Word-Brief" Command="{Binding CreateLetterCommand}"
<Button Content="PDF-Brief" Command="{Binding CreateLetterCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
IsVisible="{Binding !IsEditing}"/>
@@ -8,6 +8,7 @@ using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Views.Students;
@@ -57,7 +58,8 @@ public partial class StudentDetailView : UserControl
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>(),
App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new CreateLetterDialog { DataContext = vm };