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([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
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.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -342,7 +342,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
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.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -372,7 +372,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
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.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -406,7 +406,7 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
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.BuildMcpClientRegistrationService(),
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<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// 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 Microsoft.Extensions.DependencyInjection;
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace LehrerApp.Desktop.ViewModels;
@@ -38,6 +39,9 @@ public partial class MainWindowViewModel : ObservableObject
public bool IsClassTeacherActive => ActiveNavItem == NavItem.ClassTeacher;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public GroupDetailViewModel? CurrentGroupDetail => CurrentPage as GroupDetailViewModel;
public bool CanPersonalizeWorksheet => CurrentGroupDetail?.Group is not null;
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy,
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)
{
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 IShorthandCodeRepository _shorthandCodes;
private readonly TemplateStore _letterTemplates;
private readonly WorksheetTemplateStore _worksheetTemplates;
[ObservableProperty] private int _activeTabIndex;
@@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
WorksheetTemplateStore worksheetTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
Services.Mcp.McpClientRegistrationService mcpRegistration,
WebUntisSettingsService untisSettings,
@@ -139,6 +141,7 @@ public partial class SettingsViewModel : ObservableObject
_periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates;
_worksheetTemplates = worksheetTemplates;
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_mcpSettings = mcpSettings;
@@ -169,6 +172,7 @@ public partial class SettingsViewModel : ObservableObject
LoadPeriodTimes();
LoadSupervisionDuties();
LoadLetterTemplates();
LoadWorksheetTemplates();
LoadAiSettings();
LoadMcpSettings();
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="Hausaufgabenabgleich…" Click="OnCompareUntisHausaufgaben"/>
</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>
<DrawerPage x:Name="RootDrawer"
DrawerLength="220"
@@ -1,10 +1,17 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
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.Sync;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views;
@@ -45,6 +52,25 @@ public partial class MainWindow : Window
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)
{
if (DataContext is not MainWindowViewModel vm) return;
@@ -393,6 +393,66 @@
<Separator/>
<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"/>
<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>
</ScrollViewer>
</ContentPage>
@@ -277,4 +277,26 @@ public partial class SettingsView : UserControl
if (!File.Exists(path)) return;
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.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Views.Students;
@@ -56,16 +55,7 @@ public partial class StudentDetailView : UserControl
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
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 });
await LetterDialogs.ShowCreateLetterDialogAsync(owner, student);
}
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 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);
_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);
}
}
/// <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.
- [ ] **11.5b** Stapelerzeugung für eine ganze Lerngruppe mit Kontaktauswahl je Schüler und
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).
---