feat: Formulare-Menü mit Elternbrief-Einstieg + Arbeitsblatt-Personalisierung (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s

Neue Menü-Kategorie "Formulare" in der Menüleiste: "Elternbrief erzeugen..." öffnet
jetzt einen Schüler-Picker statt nur aus der Schülerdetailansicht erreichbar zu sein,
"Arbeitsblatt personalisieren..." ist aktiv, sobald eine einzelne Lerngruppe geöffnet
ist, und erzeugt aus einer in TemplateDesigner gebauten .lavorlage-Vorlage ein PDF je
aktivem Gruppenmitglied - über eine von den Elternbrief-Vorlagen getrennte
WorksheetTemplateStore-Bibliothek.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 00:21:08 +02:00
co-authored by Claude Sonnet 5
parent bfe214ecf2
commit 56ab5067e6
22 changed files with 715 additions and 17 deletions
@@ -0,0 +1,76 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class PersonalizeWorksheetDialogViewModelTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-worksheet-vm-tests-{Guid.NewGuid():N}");
public PersonalizeWorksheetDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact]
public void Generate_ErzeugtEinePdfJeAusgewaehltemSchueler()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Equal(2, vm.Results.Count);
Assert.All(vm.Results, r => Assert.True(r.Success));
Assert.Equal(2, Directory.GetFiles(output, "*.pdf").Length);
}
[Fact]
public void Generate_AbgewaehlterSchueler_WirdUebersprungen()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
vm.Students.Single(s => s.Model.Id == ben.Id).IsIncluded = false;
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Single(vm.Results);
Assert.Equal(anna.FullName, vm.Results[0].StudentName);
}
[Fact]
public void OhneVorlage_GenerateTutNichts()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var vm = Build(group, [anna], new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, "empty-store"))));
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Empty(vm.Results);
}
private static PersonalizeWorksheetDialogViewModel Build(LearningGroup group, List<Student> students, WorksheetTemplateStore store) =>
new(group, [.. students.Select(s => s.Id)], new FakeStudents(students), store, new QuestTemplateRenderer());
private WorksheetTemplateStore StoreWithTemplate()
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest
{
Id = $"arbeitsblatt-{Guid.NewGuid():N}", Name = "Arbeitsblatt",
Placeholders = [new PlaceholderDefinition("Student.FirstName", PlaceholderType.Text)],
};
TemplatePackage.Create(source, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Student.FirstName", new Dictionary<string, byte[]>());
var store = new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")));
store.Store.Import(source);
return store;
}
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
}
@@ -35,7 +35,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(), TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
@@ -342,7 +342,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(), TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
@@ -372,7 +372,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(), TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
@@ -406,7 +406,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(), TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(), TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
@@ -0,0 +1,49 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class StudentPickerDialogViewModelTests
{
private static (StudentPickerDialogViewModel Vm, Student Anna, Student Ben) Build()
{
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = new StudentPickerDialogViewModel(new FakeStudents([anna, ben]));
return (vm, anna, ben);
}
[Fact]
public void SearchText_FiltertNachTeilstring()
{
var (vm, anna, _) = Build();
vm.SearchText = "ann";
Assert.Single(vm.Students);
Assert.Equal(anna.FullName, vm.Students[0].FullName);
}
[Fact]
public void Select_OhneAuswahl_SetztValidationMessage()
{
var (vm, _, _) = Build();
vm.SelectCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.ValidationMessage);
}
[Fact]
public void Select_MitAuswahl_LiefertVollenSchueler()
{
var (vm, anna, _) = Build();
vm.SelectedStudent = vm.Students.Single(s => s.Id == anna.Id);
vm.SelectCommand.Execute(null);
Assert.Equal(anna.Id, vm.Result?.Id);
}
}
+1
View File
@@ -123,6 +123,7 @@ public static class AppBootstrapper
services.AddSingleton<ITemplateLoader, TemplateLoader>(); services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>(); services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData)); services.AddSingleton(_ => new TemplateStore(appData));
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
// ── Datensicherheit (13.3) ─────────────────────────────────────────── // ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
@@ -0,0 +1,28 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
/// erst per Picker gewählt) kommt.</summary>
public static class LetterDialogs
{
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
{
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
}
@@ -0,0 +1,86 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Erzeugt aus einer in TemplateDesigner gebauten Arbeitsblatt-.lavorlage-Vorlage ein
/// personalisiertes PDF je aktivem Mitglied der aktuell geöffneten Lerngruppe. Nutzt bewusst
/// dieselbe Platzhalter-/Rendering-Infrastruktur wie der Elternbrief-Dialog
/// (<see cref="LetterPlaceholderBuilder"/>, <see cref="ITemplateRenderer"/>), aber eine getrennte
/// <see cref="WorksheetTemplateStore"/>-Vorlagenbibliothek.</summary>
public partial class PersonalizeWorksheetDialogViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WorksheetTemplateStore _templates;
private readonly ITemplateRenderer _renderer;
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private string _statusMessage = "";
public string GroupName => $"{_group.Name} · {_group.SchoolYear}";
public ObservableCollection<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<WorksheetStudentChoice> Students { get; } = [];
public ObservableCollection<WorksheetGenerationResult> Results { get; } = [];
public bool HasNoTemplates => Templates.Count == 0;
public bool HasResults => Results.Count > 0;
public PersonalizeWorksheetDialogViewModel(LearningGroup group, IReadOnlyList<Guid> studentIds,
IStudentRepository students, WorksheetTemplateStore templates, ITemplateRenderer renderer)
{
_group = group; _templates = templates; _renderer = renderer;
foreach (var template in templates.Store.GetTemplates()) Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault();
foreach (var id in studentIds)
if (students.GetById(id) is { } student) Students.Add(new(student));
}
public void Generate(string outputFolder)
{
Results.Clear();
if (SelectedTemplate is null) return;
LoadedTemplate loaded;
try { loaded = _templates.Store.Load(SelectedTemplate.Model); }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ StatusMessage = $"Vorlage ist ungültig: {ex.Message}"; return; }
Directory.CreateDirectory(outputFolder);
foreach (var choice in Students.Where(x => x.IsIncluded))
{
var student = choice.Model;
try
{
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact: null, _group,
DateOnly.FromDateTime(DateTime.Now), "", "");
var pdf = _renderer.RenderToPdf(loaded, new LetterDataProvider(values));
var fileName = SanitizeFileName($"{SelectedTemplate.Name}_{student.LastName}_{student.FirstName}.pdf");
File.WriteAllBytes(Path.Combine(outputFolder, fileName), pdf);
Results.Add(new(student.FullName, true, ""));
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ Results.Add(new(student.FullName, false, ex.Message)); }
}
OnPropertyChanged(nameof(HasResults));
StatusMessage = $"{Results.Count(x => x.Success)} von {Results.Count} Arbeitsblättern erzeugt.";
}
private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
}
public partial class WorksheetStudentChoice(Student model) : ObservableObject
{
public Student Model { get; } = model;
public string FullName => Model.FullName;
[ObservableProperty] private bool _isIncluded = true;
}
public sealed record WorksheetGenerationResult(string StudentName, bool Success, string ErrorMessage)
{
public string Icon => Success ? "✓" : "⚠";
public string Color => Success ? "SeaGreen" : "#D97706";
}
@@ -12,6 +12,7 @@ using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload; using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
namespace LehrerApp.Desktop.ViewModels; namespace LehrerApp.Desktop.ViewModels;
@@ -38,6 +39,9 @@ public partial class MainWindowViewModel : ObservableObject
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher; public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings; public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public GroupDetailViewModel? CurrentGroupDetail => CurrentPage as GroupDetailViewModel;
public bool CanPersonalizeWorksheet => CurrentGroupDetail?.Group is not null;
public MainWindowViewModel(IServiceProvider services, public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy, DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock, SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock,
@@ -103,6 +107,25 @@ public partial class MainWindowViewModel : ObservableObject
} }
} }
// GroupDetailViewModel.Group wird erst nach dem Wechsel von CurrentPage per LoadGroup gesetzt
// (siehe Kommentar in NavigateToGroupDetail) - ohne dieses Abonnement bliebe
// CanPersonalizeWorksheet bis zum nächsten Seitenwechsel auf dem alten Stand.
private GroupDetailViewModel? _observedGroupDetail;
partial void OnCurrentPageChanged(ObservableObject? value)
{
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged -= OnGroupDetailPropertyChanged;
_observedGroupDetail = value as GroupDetailViewModel;
if (_observedGroupDetail is not null) _observedGroupDetail.PropertyChanged += OnGroupDetailPropertyChanged;
OnPropertyChanged(nameof(CurrentGroupDetail));
OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
private void OnGroupDetailPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(GroupDetailViewModel.Group)) OnPropertyChanged(nameof(CanPersonalizeWorksheet));
}
partial void OnActiveNavItemChanged(NavItem value) partial void OnActiveNavItemChanged(NavItem value)
{ {
OnPropertyChanged(nameof(IsDashboardActive)); OnPropertyChanged(nameof(IsDashboardActive));
@@ -0,0 +1,60 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
[ObservableProperty] private string _worksheetTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> WorksheetTemplateList { get; } = [];
private void LoadWorksheetTemplates()
{
WorksheetTemplateList.Clear();
foreach (var template in _worksheetTemplates.Store.GetTemplates()) WorksheetTemplateList.Add(CreateWorksheetItem(template));
}
public void ImportWorksheetTemplate(string path)
{
WorksheetTemplateStatus = "";
try
{
var template = _worksheetTemplates.Store.Import(path);
LoadWorksheetTemplates();
WorksheetTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert.";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ WorksheetTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; }
}
[RelayCommand]
private void ValidateWorksheetTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
try
{
var refreshed = CreateWorksheetItem(item.Model);
var index = WorksheetTemplateList.IndexOf(item);
if (index >= 0) WorksheetTemplateList[index] = refreshed;
WorksheetTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion}).";
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ WorksheetTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; }
}
[RelayCommand]
private void DeleteWorksheetTemplate(LetterTemplateListItem? item)
{
if (item is null) return;
_worksheetTemplates.Store.Delete(item.Id); WorksheetTemplateList.Remove(item); WorksheetTemplateStatus = "Vorlage gelöscht.";
}
private LetterTemplateListItem CreateWorksheetItem(InstalledTemplate template)
{
var loaded = _worksheetTemplates.Store.Load(template);
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
}
}
@@ -62,6 +62,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IShorthandCodeRepository _shorthandCodes; private readonly IShorthandCodeRepository _shorthandCodes;
private readonly TemplateStore _letterTemplates; private readonly TemplateStore _letterTemplates;
private readonly WorksheetTemplateStore _worksheetTemplates;
[ObservableProperty] private int _activeTabIndex; [ObservableProperty] private int _activeTabIndex;
@@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates, ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
WorksheetTemplateStore worksheetTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings, AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
Services.Mcp.McpClientRegistrationService mcpRegistration, Services.Mcp.McpClientRegistrationService mcpRegistration,
WebUntisSettingsService untisSettings, WebUntisSettingsService untisSettings,
@@ -139,6 +141,7 @@ public partial class SettingsViewModel : ObservableObject
_periodSchedule = periodSchedule; _periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties; _supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates; _letterTemplates = letterTemplates;
_worksheetTemplates = worksheetTemplates;
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_mcpSettings = mcpSettings; _mcpSettings = mcpSettings;
@@ -169,6 +172,7 @@ public partial class SettingsViewModel : ObservableObject
LoadPeriodTimes(); LoadPeriodTimes();
LoadSupervisionDuties(); LoadSupervisionDuties();
LoadLetterTemplates(); LoadLetterTemplates();
LoadWorksheetTemplates();
LoadAiSettings(); LoadAiSettings();
LoadMcpSettings(); LoadMcpSettings();
LoadMcpRegistrationStatus(); LoadMcpRegistrationStatus();
@@ -0,0 +1,48 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>Einfacher "einen Schüler wählen"-Dialog für Einstiegspunkte ohne bereits geöffneten
/// Schüler (z.B. das Formulare-Menü) - anders als <see cref="Groups.AddStudentToGroupDialogViewModel"/>
/// ohne Gruppenbezug/Mitgliedschaftszeitraum, einfach alle Schüler durchsuchbar.</summary>
public partial class StudentPickerDialogViewModel : ObservableObject
{
private readonly IStudentRepository _students;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private StudentPickerItem? _selectedStudent;
[ObservableProperty] private string _validationMessage = "";
public ObservableCollection<StudentPickerItem> Students { get; } = [];
public Student? Result { get; private set; }
public StudentPickerDialogViewModel(IStudentRepository students)
{
_students = students;
LoadStudents();
}
partial void OnSearchTextChanged(string value) => LoadStudents();
private void LoadStudents()
{
var matches = _students.GetAll()
.Where(s => string.IsNullOrWhiteSpace(SearchText) ||
s.FullName.Contains(SearchText, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.FullName, StringComparer.CurrentCultureIgnoreCase);
Students.Clear();
foreach (var student in matches) Students.Add(new StudentPickerItem(student));
}
[RelayCommand]
private void Select()
{
if (SelectedStudent is null) { ValidationMessage = "Bitte einen Schüler auswählen."; return; }
Result = _students.GetById(SelectedStudent.Id);
}
}
@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.PersonalizeWorksheetDialog"
x:DataType="vm:PersonalizeWorksheetDialogViewModel"
Title="Arbeitsblatt personalisieren"
Width="460" Height="620"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="4" Margin="0,0,0,14">
<TextBlock Text="Arbeitsblatt personalisieren" Classes="dialogtitle"/>
<TextBlock Text="{Binding GroupName}" FontSize="12" Opacity="0.65"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="10" Margin="0,0,0,14">
<TextBlock Text="Vorlage" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
HorizontalAlignment="Stretch" DisplayMemberBinding="{Binding Name}"/>
<TextBlock Text="Noch keine Arbeitsblatt-Vorlage importiert — in den Einstellungen unter „Briefvorlagen“ eine .lavorlage-Datei aus dem Vorlagen-Designer hinzufügen."
Classes="emptyhint" TextWrapping="Wrap"
IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="4">
<TextBlock Text="Für diese Schüler erzeugen" FontSize="12" FontWeight="SemiBold" Opacity="0.7" Margin="0,0,0,4"/>
<ItemsControl ItemsSource="{Binding Students}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorksheetStudentChoice">
<CheckBox Content="{Binding FullName}" IsChecked="{Binding IsIncluded}" Margin="0,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Results}" Margin="0,10,0,0" IsVisible="{Binding HasResults}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorksheetGenerationResult">
<Grid ColumnDefinitions="20,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" FontSize="12">
<Run Text="{Binding StudentName}"/><Run Text=" "/><Run Text="{Binding ErrorMessage}" Foreground="#D97706"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid Grid.Row="4" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
<Button Grid.Column="0" Content="Schließen" HorizontalAlignment="Stretch" Click="OnClose"/>
<Button Grid.Column="2" Content="Erzeugen…" HorizontalAlignment="Stretch" Click="OnGenerate"
IsEnabled="{Binding !HasNoTemplates}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class PersonalizeWorksheetDialog : Window
{
public PersonalizeWorksheetDialog() => InitializeComponent();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not PersonalizeWorksheetDialogViewModel vm) return;
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Zielordner für die personalisierten Arbeitsblätter wählen",
AllowMultiple = false,
});
if (folders.Count == 0) return;
vm.Generate(folders[0].Path.LocalPath);
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
+6
View File
@@ -40,6 +40,12 @@
<MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/> <MenuItem Header="Klassenbuchabgleich…" Click="OnCompareUntisKlassenbuch"/>
<MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/> <MenuItem Header="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
</MenuItem> </MenuItem>
<MenuItem Header="Formulare">
<MenuItem Header="Elternbrief erzeugen…" Click="OnCreateParentLetter"/>
<MenuItem Header="Arbeitsblatt personalisieren…" Click="OnPersonalizeWorksheet"
IsEnabled="{Binding CanPersonalizeWorksheet}"
ToolTip.Tip="Nur verfügbar, während eine einzelne Lerngruppe geöffnet ist"/>
</MenuItem>
</Menu> </Menu>
<DrawerPage x:Name="RootDrawer" <DrawerPage x:Name="RootDrawer"
DrawerLength="220" DrawerLength="220"
@@ -1,10 +1,17 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.UntisHub; using LehrerApp.Desktop.Views.UntisHub;
using LehrerApp.Sync; using LehrerApp.Sync;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views; namespace LehrerApp.Desktop.Views;
@@ -45,6 +52,25 @@ public partial class MainWindow : Window
await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>()); await UntisHubActions.RunHausaufgabenAsync(this, App.Services.GetRequiredService<UntisHubService>());
} }
private async void OnCreateParentLetter(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var picker = new StudentPickerDialog
{ DataContext = new StudentPickerDialogViewModel(App.Services.GetRequiredService<IStudentRepository>()) };
if (await picker.ShowDialog<Student?>(this) is { } student)
await LetterDialogs.ShowCreateLetterDialogAsync(this, student);
}
private async void OnPersonalizeWorksheet(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel { CurrentGroupDetail.Group: { } group } vm) return;
var studentIds = vm.CurrentGroupDetail!.Students.Select(s => s.Id).ToList();
var dialogVm = new PersonalizeWorksheetDialogViewModel(group, studentIds,
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<WorksheetTemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>());
await new PersonalizeWorksheetDialog { DataContext = dialogVm }.ShowDialog(this);
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e) private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{ {
if (DataContext is not MainWindowViewModel vm) return; if (DataContext is not MainWindowViewModel vm) return;
@@ -393,6 +393,66 @@
<Separator/> <Separator/>
<TextBlock Text="Vorlagen werden mit dem separaten LehrerApp Vorlagen-Designer erstellt. Designer und Hauptapp verwenden exakt dieselbe QuestPDF-Renderingbibliothek." <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"/> FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Separator Margin="0,8"/>
<TextBlock Text="Arbeitsblatt-Vorlagen" FontSize="15" FontWeight="SemiBold"/>
<TextBlock Text="Eigene Vorlagenbibliothek für „Arbeitsblatt personalisieren…“ im Formulare-Menü — getrennt von den Elternbrief-Vorlagen oben, damit sich beide Listen nicht vermischen."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<Button Grid.Column="0" Content=" .lavorlage importieren" Click="OnImportWorksheetTemplateClick"/>
<TextBlock Grid.Column="1" Text="{Binding WorksheetTemplateStatus}" FontSize="12"
VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding WorksheetTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<TextBlock Text="Noch keine Arbeitsblatt-Vorlage importiert." Classes="emptyhint"
IsVisible="{Binding !WorksheetTemplateList.Count}"/>
<ItemsControl ItemsSource="{Binding WorksheetTemplateList}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7" Padding="14,12" Margin="0,0,0,9">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
<TextBlock FontSize="11" Opacity="0.55">
<Run Text="{Binding PackageFileName}"/><Run Text=" · "/>
<Run Text="{Binding ValidationSummary}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Öffnen" FontSize="12" Padding="10,4"
Margin="8,0,0,0" Tag="{Binding}" Click="OnOpenWorksheetTemplateClick"/>
<Button Grid.Column="2" Content="Neu prüfen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).ValidateWorksheetTemplateCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="3" Content="Löschen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteWorksheetTemplateCommand}"
CommandParameter="{Binding}"/>
</Grid>
<ItemsControl ItemsSource="{Binding Issues}" IsVisible="{Binding HasIssues}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateIssueItem">
<Grid ColumnDefinitions="24,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" Foreground="{Binding Color}"
FontSize="12" TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="✓ Vorlage ohne Auffälligkeiten" Foreground="SeaGreen" FontSize="12"
IsVisible="{Binding HasNoIssues}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
@@ -277,4 +277,26 @@ public partial class SettingsView : UserControl
if (!File.Exists(path)) return; if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
} }
private async void OnImportWorksheetTemplateClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not SettingsViewModel vm) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "LehrerApp-Arbeitsblattvorlage importieren",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("LehrerApp-Vorlagen") { Patterns = ["*.lavorlage"] }],
});
if (files.Count > 0) vm.ImportWorksheetTemplate(files[0].Path.LocalPath);
}
private void OnOpenWorksheetTemplateClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: LetterTemplateListItem item }) return;
var path = item.Model.PackagePath;
if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
} }
@@ -8,7 +8,6 @@ using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared; using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Views.Students; namespace LehrerApp.Desktop.Views.Students;
@@ -56,16 +55,7 @@ public partial class StudentDetailView : UserControl
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return; if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
} }
private void ShowAddressViewer(ContactItem contact) private void ShowAddressViewer(ContactItem contact)
@@ -0,0 +1,41 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Students.StudentPickerDialog"
x:DataType="vm:StudentPickerDialogViewModel"
Title="Schüler wählen"
Width="380" Height="480"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12" Margin="0,0,0,12">
<TextBlock Text="Schüler wählen" Classes="dialogtitle"/>
<TextBox Text="{Binding SearchText}"
PlaceholderText="Schüler suchen …"
x:Name="SearchBox"/>
</StackPanel>
<ListBox Grid.Row="1"
ItemsSource="{Binding Students}"
SelectedItem="{Binding SelectedStudent}"
BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vmg:StudentPickerItem">
<TextBlock Text="{Binding FullName}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="2" Spacing="12" Margin="0,16,0,0">
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Weiter" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class StudentPickerDialog : Window
{
public StudentPickerDialog() => InitializeComponent();
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
this.FindControl<TextBox>("SearchBox")?.Focus();
}
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not StudentPickerDialogViewModel vm) return;
vm.SelectCommand.Execute(null);
if (vm.Result is not null) Close(vm.Result);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -0,0 +1,29 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Templating.Tests;
public sealed class TemplateStoreTests : IDisposable
{
private readonly string _appData = Path.Combine(Path.GetTempPath(), $"lavorlage-store-{Guid.NewGuid():N}");
[Fact]
public void ZweiStoresMitUnterschiedlichemUnterordner_TeilenSichKeineVorlagen()
{
var letters = new TemplateStore(_appData);
var worksheets = new TemplateStore(_appData, subfolder: "worksheet-template-packages");
var packagePath = Path.Combine(_appData, "source.lavorlage");
TemplatePackage.Create(packagePath, new TemplateManifest { Id = "brief", Name = "Brief" },
"PAGE 210 297 mm\nTEXT 20 20 \"Text\"", new Dictionary<string, byte[]>());
letters.Import(packagePath);
Assert.Single(letters.GetTemplates());
Assert.Empty(worksheets.GetTemplates());
}
public void Dispose()
{
if (Directory.Exists(_appData)) Directory.Delete(_appData, recursive: true);
}
}
+10 -2
View File
@@ -70,9 +70,9 @@ public sealed class TemplateStore
private readonly string _directory; private readonly string _directory;
private readonly ITemplateLoader _loader; private readonly ITemplateLoader _loader;
public TemplateStore(string appDataPath, ITemplateLoader? loader = null) public TemplateStore(string appDataPath, ITemplateLoader? loader = null, string subfolder = "letter-template-packages")
{ {
_directory = Path.Combine(appDataPath, "letter-template-packages"); _directory = Path.Combine(appDataPath, subfolder);
Directory.CreateDirectory(_directory); Directory.CreateDirectory(_directory);
_loader = loader ?? new TemplateLoader(); _loader = loader ?? new TemplateLoader();
} }
@@ -112,3 +112,11 @@ public sealed class TemplateStore
if (match is not null && File.Exists(match.PackagePath)) File.Delete(match.PackagePath); if (match is not null && File.Exists(match.PackagePath)) File.Delete(match.PackagePath);
} }
} }
/// <summary>Getrennter Vorlagen-Speicher für Arbeitsblätter (eigener Unterordner) - reiner
/// Marker-Typ, damit DI die Elternbrief- und die Arbeitsblatt-Vorlagenbibliothek als zwei
/// unabhängige <see cref="TemplateStore"/>-Instanzen registrieren kann.</summary>
public sealed class WorksheetTemplateStore(TemplateStore store)
{
public TemplateStore Store { get; } = store;
}
+29
View File
@@ -4324,6 +4324,35 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
Erzeugung, damit fehlerhafte Briefe nicht unbemerkt vervielfältigt werden. Erzeugung, damit fehlerhafte Briefe nicht unbemerkt vervielfältigt werden.
- [ ] **11.5b** Stapelerzeugung für eine ganze Lerngruppe mit Kontaktauswahl je Schüler und - [ ] **11.5b** Stapelerzeugung für eine ganze Lerngruppe mit Kontaktauswahl je Schüler und
Ergebnisübersicht. Bewusst nachgelagert; der validierte Einzelbrief bildet die Grundlage. Ergebnisübersicht. Bewusst nachgelagert; der validierte Einzelbrief bildet die Grundlage.
- [x] **11.5c** Formulare-Menü in der Menüleiste (`MainWindow.axaml`, bisher nur "WebUntis"):
"Elternbrief erzeugen…" ohne vorher geöffneten Schüler sowie neu "Arbeitsblatt
personalisieren…", das nur aktiv ist, während eine einzelne Lerngruppe in der Detailansicht
geöffnet ist (Nutzer-Feedback).
**Elternbrief-Einstieg:** neuer `StudentPickerDialog`
([StudentPickerDialogViewModel.cs](LehrerApp.Desktop/ViewModels/Students/StudentPickerDialogViewModel.cs))
mit demselben Such-/Filtermuster wie `AddStudentToGroupDialogViewModel`, aber ohne
Gruppenbezug. Danach läuft unverändert der bestehende `CreateLetterDialog`; der gemeinsame
Öffnen-Ablauf wurde aus `StudentDetailView` in `LetterDialogs.ShowCreateLetterDialogAsync`
ausgelagert, damit beide Einstiegspunkte (Schülerdetail + Menü) nicht auseinanderlaufen.
Deckt weiterhin nur einen Schüler auf einmal ab — die in 11.5b beschriebene
Stapelerzeugung für eine ganze Lerngruppe bleibt offen.
**Arbeitsblatt personalisieren:** nutzt dieselbe .lavorlage/QuestPDF-Infrastruktur wie
Elternbriefe (`LehrerApp.Templating`), aber eine eigene `WorksheetTemplateStore`-Bibliothek
(`TemplateStore` bekam dafür einen optionalen `subfolder`-Konstruktorparameter) mit
eigener Verwaltung unter Einstellungen → Briefvorlagen → "Arbeitsblatt-Vorlagen" — bewusst
getrennt von den Elternbrief-Vorlagen, damit sich beide Listen nicht vermischen. Der
.lavorlage-Import selbst (PDF-Beispiel einlesen, Platzhalter erkennen) passiert weiterhin
ausschließlich im externen `LehrerApp.TemplateDesigner` (siehe 4.5.29); die Hauptapp lädt nur
eine fertige Vorlage, befüllt sie je aktivem Gruppenmitglied mit den bereits für Elternbriefe
etablierten `LetterPlaceholderBuilder`-Platzhaltern (Student.FirstName/LastName, Group.Name,
Datum, …) und schreibt je Schüler ein PDF in einen gewählten Zielordner
(`PersonalizeWorksheetDialogViewModel`). Bewusst kein Sammel-PDF und keine grafische
Platzhalter-Positionierung in der Hauptapp — beides bleibt Aufgabe des Vorlagen-Designers.
`MainWindowViewModel.CanPersonalizeWorksheet` beobachtet dafür `GroupDetailViewModel.Group`
per PropertyChanged-Abo, weil dessen Wert erst nach dem Seitenwechsel per `LoadGroup` gesetzt
wird (siehe Kommentar in `NavigateToGroupDetail`).
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung). - [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
--- ---