feat: Formulare-Menü mit Elternbrief-Einstieg + Arbeitsblatt-Personalisierung (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user