This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user