Fehlerbehandlung, Logging und feldbezogene Validierung (Kapitel 13.2)
Zentrale Exception-Behandlung (Dispatcher.UIThread.UnhandledException, AppDomain, TaskScheduler) verhindert Abstürze und protokolliert Fehler über AppLogger in eine rotierende Log-Datei im App-Datenverzeichnis. Toast-Benachrichtigungen zeigen Erfolg/Fehler global an. Alle Dialoge mit Formularfeldern zeigen Validierungsmeldungen jetzt direkt am betroffenen Feld statt in einem Sammel-Label. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
public enum LogLevel { Info, Warning, Error }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schreibt Log-Zeilen in eine tägliche Datei im App-Datenverzeichnis
|
||||||
|
/// (<c><AppData>/LehrerApp/logs/lehrerapp-JJJJ-MM-TT.log</c>).
|
||||||
|
/// Beim Start werden Dateien entfernt, die älter als <see cref="RetentionDays"/> sind.
|
||||||
|
/// </summary>
|
||||||
|
public class AppLogger
|
||||||
|
{
|
||||||
|
private const int RetentionDays = 14;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly string _logDirectory;
|
||||||
|
|
||||||
|
public string LogDirectory => _logDirectory;
|
||||||
|
|
||||||
|
public AppLogger(string appDataPath)
|
||||||
|
{
|
||||||
|
_logDirectory = Path.Combine(appDataPath, "logs");
|
||||||
|
Directory.CreateDirectory(_logDirectory);
|
||||||
|
RotateOldFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Info(string message) => Write(LogLevel.Info, message, null);
|
||||||
|
public void Warn(string message) => Write(LogLevel.Warning, message, null);
|
||||||
|
public void Error(string message, Exception? exception = null) => Write(LogLevel.Error, message, exception);
|
||||||
|
|
||||||
|
private void Write(LogLevel level, string message, Exception? exception)
|
||||||
|
{
|
||||||
|
var levelLabel = level switch
|
||||||
|
{
|
||||||
|
LogLevel.Info => "INFO ",
|
||||||
|
LogLevel.Warning => "WARN ",
|
||||||
|
LogLevel.Error => "ERROR",
|
||||||
|
_ => "? ",
|
||||||
|
};
|
||||||
|
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{levelLabel}] {message}";
|
||||||
|
if (exception is not null)
|
||||||
|
line += Environment.NewLine + exception;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.AppendAllText(CurrentLogFile(), line + Environment.NewLine);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Logging darf die App nie zum Absturz bringen (z.B. Datenträger voll/gesperrt).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CurrentLogFile() =>
|
||||||
|
Path.Combine(_logDirectory, $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log");
|
||||||
|
|
||||||
|
private void RotateOldFiles()
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.Now.AddDays(-RetentionDays);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.GetFiles(_logDirectory, "lehrerapp-*.log"))
|
||||||
|
{
|
||||||
|
if (File.GetLastWriteTime(file) < cutoff)
|
||||||
|
{
|
||||||
|
try { File.Delete(file); }
|
||||||
|
catch { /* nächster Start versucht es erneut */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Verzeichnis nicht lesbar o.ä. — Logging soll trotzdem weiterlaufen können.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
@@ -18,6 +20,8 @@ public class App : Application
|
|||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
Services = AppBootstrapper.BuildServices();
|
Services = AppBootstrapper.BuildServices();
|
||||||
|
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||||
|
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
|
|||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Data.Repositories;
|
using LehrerApp.Data.Repositories;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
@@ -24,20 +25,44 @@ public static class AppBootstrapper
|
|||||||
public static string DbPath { get; private set; } = "";
|
public static string DbPath { get; private set; } = "";
|
||||||
public static string AppDataPath { get; private set; } = "";
|
public static string AppDataPath { get; private set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen
|
||||||
|
/// Exception-Handler in Program.cs, der schon vor <see cref="BuildServices"/> greifen muss).
|
||||||
|
/// </summary>
|
||||||
|
public static AppLogger Logger { get; private set; } = null!;
|
||||||
|
|
||||||
|
public static string ResolveAppDataPath()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(AppDataPath)) return AppDataPath;
|
||||||
|
var appData = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"LehrerApp");
|
||||||
|
Directory.CreateDirectory(appData);
|
||||||
|
AppDataPath = appData;
|
||||||
|
return appData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppLogger EnsureLogger()
|
||||||
|
{
|
||||||
|
Logger ??= new AppLogger(ResolveAppDataPath());
|
||||||
|
return Logger;
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceProvider BuildServices()
|
public static IServiceProvider BuildServices()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
// ── Pfade ─────────────────────────────────────────────────────────────
|
// ── Pfade ─────────────────────────────────────────────────────────────
|
||||||
var appData = Path.Combine(
|
var appData = ResolveAppDataPath();
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"LehrerApp");
|
|
||||||
Directory.CreateDirectory(appData);
|
|
||||||
AppDataPath = appData;
|
|
||||||
DbPath = Path.Combine(appData, "lehrerapp.db");
|
DbPath = Path.Combine(appData, "lehrerapp.db");
|
||||||
var queuePath = Path.Combine(appData, "syncqueue.db");
|
var queuePath = Path.Combine(appData, "syncqueue.db");
|
||||||
var keyPath = Path.Combine(appData, "sync.key");
|
var keyPath = Path.Combine(appData, "sync.key");
|
||||||
|
|
||||||
|
// ── Logging & Benachrichtigungen ─────────────────────────────────────────
|
||||||
|
EnsureLogger();
|
||||||
|
services.AddSingleton(Logger);
|
||||||
|
services.AddSingleton<NotificationService>();
|
||||||
|
|
||||||
// ── Datenbank ─────────────────────────────────────────────────────────
|
// ── Datenbank ─────────────────────────────────────────────────────────
|
||||||
services.AddSingleton(_ => new LiteDbContext(DbPath));
|
services.AddSingleton(_ => new LiteDbContext(DbPath));
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop;
|
namespace LehrerApp.Desktop;
|
||||||
|
|
||||||
class Program
|
class Program
|
||||||
{
|
{
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) =>
|
public static void Main(string[] args)
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
{
|
||||||
|
var logger = AppBootstrapper.EnsureLogger();
|
||||||
|
GlobalExceptionHandler.Install(logger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Fehler, die den Avalonia-Nachrichtenloop selbst verlassen (z.B. beim Start),
|
||||||
|
// laufen hier zusammen — protokollieren, bevor der Prozess unvermeidlich endet.
|
||||||
|
logger.Error("Unbehandelter Fehler hat die Anwendung beendet.", ex);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
AppBuilder.Configure<App>()
|
AppBuilder.Configure<App>()
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zentrale Exception-Behandlung (13.2.1): protokolliert jeden unerwarteten Fehler und zeigt
|
||||||
|
/// eine verständliche Meldung statt eines rohen Absturzes.
|
||||||
|
///
|
||||||
|
/// Reichweite ehrlich betrachtet: <see cref="Dispatcher"/>.UIThread.UnhandledException kann
|
||||||
|
/// Ausnahmen aus Befehlen/Ereignis-Handlern auf dem UI-Thread abfangen und die App am Leben
|
||||||
|
/// erhalten (Handled = true) — das deckt den weit überwiegenden Teil realer Abstürze in einer
|
||||||
|
/// Desktop-App ab. AppDomain.UnhandledException und TaskScheduler.UnobservedTaskException sind
|
||||||
|
/// Sicherheitsnetze für Fehler außerhalb des UI-Threads; die App kann eine "IsTerminating"-
|
||||||
|
/// Ausnahme dort nicht mehr verhindern, aber wenigstens vollständig protokollieren, bevor sie endet.
|
||||||
|
/// </summary>
|
||||||
|
public static class GlobalExceptionHandler
|
||||||
|
{
|
||||||
|
private static AppLogger? _logger;
|
||||||
|
private static NotificationService? _notifications;
|
||||||
|
|
||||||
|
public static void Install(AppLogger logger, NotificationService? notifications = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_notifications = notifications;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandledException;
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException;
|
||||||
|
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Muss aufgerufen werden, sobald der DI-Container steht, damit spätere Fehler
|
||||||
|
/// (die vor dem Container auftreten könnten) noch als Toast sichtbar werden.
|
||||||
|
public static void AttachNotifications(NotificationService notifications) => _notifications = notifications;
|
||||||
|
|
||||||
|
private static void OnDispatcherUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
_logger?.Error("Unbehandelter Fehler auf dem UI-Thread.", e.Exception);
|
||||||
|
_notifications?.ShowError("Es ist ein unerwarteter Fehler aufgetreten. Details wurden protokolliert.");
|
||||||
|
e.Handled = true; // App am Leben halten statt abzustürzen.
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
_logger?.Error(
|
||||||
|
$"Unbehandelter Fehler außerhalb des UI-Threads (IsTerminating={e.IsTerminating}).",
|
||||||
|
e.ExceptionObject as Exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
_logger?.Error("Unbeobachtete Ausnahme in einem Hintergrund-Task.", e.Exception);
|
||||||
|
e.SetObserved(); // verhindert, dass die Ausnahme unbemerkt verschwindet.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
public enum NotificationKind { Success, Error }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Einheitliche Erfolgs-/Fehlermeldungen als Toast (13.2.3). Wird im MainWindow als
|
||||||
|
/// Overlay über der aktuellen Seite angezeigt und blendet sich nach kurzer Zeit selbst aus.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationService
|
||||||
|
{
|
||||||
|
public ObservableCollection<ToastItem> Toasts { get; } = [];
|
||||||
|
|
||||||
|
public void ShowSuccess(string message) => Show(message, NotificationKind.Success, TimeSpan.FromSeconds(3));
|
||||||
|
public void ShowError(string message) => Show(message, NotificationKind.Error, TimeSpan.FromSeconds(6));
|
||||||
|
|
||||||
|
private void Show(string message, NotificationKind kind, TimeSpan duration)
|
||||||
|
{
|
||||||
|
void AddAndScheduleRemoval()
|
||||||
|
{
|
||||||
|
var toast = new ToastItem(message, kind);
|
||||||
|
Toasts.Add(toast);
|
||||||
|
_ = RemoveAfter(toast, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Dispatcher.UIThread.CheckAccess()) AddAndScheduleRemoval();
|
||||||
|
else Dispatcher.UIThread.Post(AddAndScheduleRemoval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveAfter(ToastItem toast, TimeSpan duration)
|
||||||
|
{
|
||||||
|
await Task.Delay(duration);
|
||||||
|
Toasts.Remove(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ToastItem : ObservableObject
|
||||||
|
{
|
||||||
|
public string Message { get; }
|
||||||
|
public NotificationKind Kind { get; }
|
||||||
|
public bool IsError => Kind == NotificationKind.Error;
|
||||||
|
|
||||||
|
public ToastItem(string message, NotificationKind kind)
|
||||||
|
{
|
||||||
|
Message = message;
|
||||||
|
Kind = kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int? _examNumber;
|
[ObservableProperty] private int? _examNumber;
|
||||||
[ObservableProperty] private string _notes = "";
|
[ObservableProperty] private string _notes = "";
|
||||||
[ObservableProperty] private string _returnedAtText = "";
|
[ObservableProperty] private string _returnedAtText = "";
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _titleError = "";
|
||||||
|
[ObservableProperty] private string _dateTextError = "";
|
||||||
|
[ObservableProperty] private string _returnedAtTextError = "";
|
||||||
[ObservableProperty] private double _totalPoints;
|
[ObservableProperty] private double _totalPoints;
|
||||||
[ObservableProperty] private bool _hasCompetencyCatalog;
|
[ObservableProperty] private bool _hasCompetencyCatalog;
|
||||||
[ObservableProperty] private bool _useWeighting;
|
[ObservableProperty] private bool _useWeighting;
|
||||||
@@ -264,27 +266,34 @@ public partial class ExamDialogViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(Title)) { ValidationMessage = "Titel erforderlich."; return; }
|
TitleError = ""; DateTextError = ""; ReturnedAtTextError = ""; GradingKeyValidation = "";
|
||||||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
var valid = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||||
|
|
||||||
|
DateOnly date = default;
|
||||||
|
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out date))
|
||||||
{
|
{
|
||||||
ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben.";
|
DateTextError = "Format TT.MM.JJJJ.";
|
||||||
return;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DateOnly? returnedAt = null;
|
DateOnly? returnedAt = null;
|
||||||
if (!string.IsNullOrWhiteSpace(ReturnedAtText))
|
if (!string.IsNullOrWhiteSpace(ReturnedAtText))
|
||||||
{
|
{
|
||||||
if (!DateOnly.TryParseExact(ReturnedAtText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
|
if (!DateOnly.TryParseExact(ReturnedAtText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
|
||||||
{
|
{
|
||||||
ValidationMessage = "Rückgabedatum im Format TT.MM.JJJJ eingeben.";
|
ReturnedAtTextError = "Format TT.MM.JJJJ.";
|
||||||
return;
|
valid = false;
|
||||||
}
|
}
|
||||||
returnedAt = r;
|
else returnedAt = r;
|
||||||
}
|
}
|
||||||
|
|
||||||
var gradingKey = BuildGradingKeyEntries();
|
var gradingKey = BuildGradingKeyEntries();
|
||||||
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
|
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
|
||||||
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; return; }
|
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
|
||||||
GradingKeyValidation = "";
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
Result = _editingExam ?? new Exam { GroupId = _groupId };
|
Result = _editingExam ?? new Exam { GroupId = _groupId };
|
||||||
Result.Title = Title.Trim();
|
Result.Title = Title.Trim();
|
||||||
|
|||||||
@@ -315,11 +315,16 @@ public partial class StudentGradesDialogViewModel : ObservableObject
|
|||||||
|
|
||||||
private void Save(GradeEditItem item)
|
private void Save(GradeEditItem item)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(item.Value)) { item.ValidationMessage = "Wert darf nicht leer sein."; return; }
|
item.ValueError = ""; item.DateTextError = "";
|
||||||
|
var valid = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Value)) { item.ValueError = "Wert darf nicht leer sein."; valid = false; }
|
||||||
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
|
if (!DateOnly.TryParseExact(item.DateText, "dd.MM.yyyy", null,
|
||||||
System.Globalization.DateTimeStyles.None, out _))
|
System.Globalization.DateTimeStyles.None, out _))
|
||||||
{ item.ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
|
{ item.DateTextError = "Format TT.MM.JJJJ."; valid = false; }
|
||||||
item.ValidationMessage = "";
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
var grade = item.ToModel();
|
var grade = item.ToModel();
|
||||||
_grades.Save(grade);
|
_grades.Save(grade);
|
||||||
item.IsNew = false;
|
item.IsNew = false;
|
||||||
@@ -345,7 +350,8 @@ public partial class GradeEditItem : ObservableObject
|
|||||||
[ObservableProperty] private string _dateText;
|
[ObservableProperty] private string _dateText;
|
||||||
[ObservableProperty] private double _weight;
|
[ObservableProperty] private double _weight;
|
||||||
[ObservableProperty] private string? _note;
|
[ObservableProperty] private string? _note;
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _valueError = "";
|
||||||
|
[ObservableProperty] private string _dateTextError = "";
|
||||||
|
|
||||||
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
||||||
public string CategoryName
|
public string CategoryName
|
||||||
@@ -405,7 +411,7 @@ public partial class CollectiveGradeDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private double _weight = 1.0;
|
[ObservableProperty] private double _weight = 1.0;
|
||||||
[ObservableProperty] private string? _note;
|
[ObservableProperty] private string? _note;
|
||||||
[ObservableProperty] private string _statusMessage = "";
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _dateTextError = "";
|
||||||
|
|
||||||
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
// Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
|
||||||
public string CategoryName
|
public string CategoryName
|
||||||
@@ -428,8 +434,8 @@ public partial class CollectiveGradeDialogViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
|
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
|
||||||
System.Globalization.DateTimeStyles.None, out var date))
|
System.Globalization.DateTimeStyles.None, out var date))
|
||||||
{ ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
|
{ DateTextError = "Format TT.MM.JJJJ."; return; }
|
||||||
ValidationMessage = "";
|
DateTextError = "";
|
||||||
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
foreach (var row in Rows)
|
foreach (var row in Rows)
|
||||||
|
|||||||
@@ -618,7 +618,8 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private int? _hoursPerWeek;
|
[ObservableProperty] private int? _hoursPerWeek;
|
||||||
[ObservableProperty] private bool _isOwnClass;
|
[ObservableProperty] private bool _isOwnClass;
|
||||||
[ObservableProperty] private bool _isDifferentiated;
|
[ObservableProperty] private bool _isDifferentiated;
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
|
[ObservableProperty] private string _gradeLevelError = "";
|
||||||
|
|
||||||
public List<string> SchoolYears { get; }
|
public List<string> SchoolYears { get; }
|
||||||
public List<string> GradingOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
public List<string> GradingOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||||
@@ -662,8 +663,13 @@ public partial class AddGroupDialogViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(Name)) { ValidationMessage = "Bezeichnung erforderlich."; return; }
|
NameError = ""; GradeLevelError = "";
|
||||||
if (GradeLevel is < 1 or > 13) { ValidationMessage = "Klassenstufe 1–13."; return; }
|
var valid = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Name)) { NameError = "Bezeichnung erforderlich."; valid = false; }
|
||||||
|
if (GradeLevel is < 1 or > 13) { GradeLevelError = "Klassenstufe 1–13."; valid = false; }
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
string? subjectName = !string.IsNullOrWhiteSpace(Subject) ? Subject.Trim() : null;
|
string? subjectName = !string.IsNullOrWhiteSpace(Subject) ? Subject.Trim() : null;
|
||||||
Guid? subjectId = null;
|
Guid? subjectId = null;
|
||||||
|
|||||||
@@ -671,7 +671,7 @@ public partial class AddSessionDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today);
|
[ObservableProperty] private DateOnly _date = DateOnly.FromDateTime(DateTime.Today);
|
||||||
[ObservableProperty] private string _comment = "";
|
[ObservableProperty] private string _comment = "";
|
||||||
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _dateTextError = "";
|
||||||
|
|
||||||
public ParticipationSession? Result { get; private set; }
|
public ParticipationSession? Result { get; private set; }
|
||||||
|
|
||||||
@@ -681,9 +681,10 @@ public partial class AddSessionDialogViewModel : ObservableObject
|
|||||||
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
|
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null,
|
||||||
System.Globalization.DateTimeStyles.None, out var date))
|
System.Globalization.DateTimeStyles.None, out var date))
|
||||||
{
|
{
|
||||||
ValidationMessage = "Datum im Format TT.MM.JJJJ eingeben.";
|
DateTextError = "Format TT.MM.JJJJ.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
DateTextError = "";
|
||||||
Result = new ParticipationSession { Date = date, Comment = Comment.Trim() };
|
Result = new ParticipationSession { Date = date, Comment = Comment.Trim() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _progressText = "";
|
[ObservableProperty] private string _progressText = "";
|
||||||
[ObservableProperty] private string _newSectionLabel = "";
|
[ObservableProperty] private string _newSectionLabel = "";
|
||||||
[ObservableProperty] private string _newSectionEndDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
[ObservableProperty] private string _newSectionEndDateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
|
||||||
[ObservableProperty] private string _sectionValidationMessage = "";
|
[ObservableProperty] private string _newSectionLabelError = "";
|
||||||
|
[ObservableProperty] private string _newSectionEndDateTextError = "";
|
||||||
[ObservableProperty] private ParticipationPeriodOption _rollupPeriod;
|
[ObservableProperty] private ParticipationPeriodOption _rollupPeriod;
|
||||||
[ObservableProperty] private string _rollupStatusMessage = "";
|
[ObservableProperty] private string _rollupStatusMessage = "";
|
||||||
[ObservableProperty] private double _timelineZoom = 1.0;
|
[ObservableProperty] private double _timelineZoom = 1.0;
|
||||||
@@ -322,13 +323,20 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseSection()
|
private void CloseSection()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewSectionLabel)) { SectionValidationMessage = "Bezeichnung erforderlich."; return; }
|
NewSectionLabelError = ""; NewSectionEndDateTextError = "";
|
||||||
|
var valid = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(NewSectionLabel)) { NewSectionLabelError = "Bezeichnung erforderlich."; valid = false; }
|
||||||
|
|
||||||
|
DateOnly end = default;
|
||||||
if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null,
|
if (!DateOnly.TryParseExact(NewSectionEndDateText, "dd.MM.yyyy", null,
|
||||||
DateTimeStyles.None, out var end))
|
DateTimeStyles.None, out end))
|
||||||
{ SectionValidationMessage = "Datum im Format TT.MM.JJJJ eingeben."; return; }
|
{ NewSectionEndDateTextError = "Format TT.MM.JJJJ."; valid = false; }
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
var start = ComputeOpenStart();
|
var start = ComputeOpenStart();
|
||||||
if (end < start) { SectionValidationMessage = "Enddatum liegt vor Abschnittsbeginn."; return; }
|
if (end < start) { NewSectionEndDateTextError = "Enddatum liegt vor Abschnittsbeginn."; return; }
|
||||||
|
|
||||||
var section = new ParticipationSection { GroupId = _groupId, Label = NewSectionLabel.Trim(), StartDate = start, EndDate = end };
|
var section = new ParticipationSection { GroupId = _groupId, Label = NewSectionLabel.Trim(), StartDate = start, EndDate = end };
|
||||||
_sectionRepo.Save(section);
|
_sectionRepo.Save(section);
|
||||||
@@ -350,7 +358,6 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionValidationMessage = "";
|
|
||||||
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
|
||||||
BuildTimeline(CurrentStudentId);
|
BuildTimeline(CurrentStudentId);
|
||||||
BuildSections(CurrentStudentId);
|
BuildSections(CurrentStudentId);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels;
|
namespace LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
@@ -17,13 +19,15 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _currentSchoolYear = "";
|
[ObservableProperty] private string _currentSchoolYear = "";
|
||||||
|
|
||||||
public SyncStatusViewModel SyncStatus { get; }
|
public SyncStatusViewModel SyncStatus { get; }
|
||||||
|
public ObservableCollection<ToastItem> Toasts { get; }
|
||||||
|
|
||||||
public MainWindowViewModel(IServiceProvider services,
|
public MainWindowViewModel(IServiceProvider services,
|
||||||
DashboardViewModel dashboard, SchoolYearService sy,
|
DashboardViewModel dashboard, SchoolYearService sy,
|
||||||
SyncStatusViewModel syncStatus)
|
SyncStatusViewModel syncStatus, NotificationService notifications)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
SyncStatus = syncStatus;
|
SyncStatus = syncStatus;
|
||||||
|
Toasts = notifications.Toasts;
|
||||||
CurrentSchoolYear = sy.CurrentSchoolYear();
|
CurrentSchoolYear = sy.CurrentSchoolYear();
|
||||||
CurrentPage = dashboard;
|
CurrentPage = dashboard;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private string _newName = "";
|
[ObservableProperty] private string _newName = "";
|
||||||
[ObservableProperty] private string _newShort = "";
|
[ObservableProperty] private string _newShort = "";
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _newNameError = "";
|
||||||
|
|
||||||
public ObservableCollection<SubjectListItem> Subjects { get; } = [];
|
public ObservableCollection<SubjectListItem> Subjects { get; } = [];
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private string _newTemplateName = "";
|
[ObservableProperty] private string _newTemplateName = "";
|
||||||
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 1–6";
|
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 1–6";
|
||||||
[ObservableProperty] private string _templateValidationMessage = "";
|
[ObservableProperty] private string _newTemplateNameError = "";
|
||||||
|
|
||||||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||||
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
||||||
@@ -93,7 +93,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddGradingKeyTemplate()
|
private void AddGradingKeyTemplate()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewTemplateName)) { TemplateValidationMessage = "Vorlagenname erforderlich."; return; }
|
if (string.IsNullOrWhiteSpace(NewTemplateName)) { NewTemplateNameError = "Vorlagenname erforderlich."; return; }
|
||||||
|
|
||||||
var system = NewTemplateGradingSystemName == "Punkte 0–15"
|
var system = NewTemplateGradingSystemName == "Punkte 0–15"
|
||||||
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
|
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
|
||||||
@@ -109,7 +109,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_gradingKeyTemplates.Save(template);
|
_gradingKeyTemplates.Save(template);
|
||||||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
|
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
|
||||||
NewTemplateName = "";
|
NewTemplateName = "";
|
||||||
TemplateValidationMessage = "";
|
NewTemplateNameError = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -132,17 +132,17 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddSubject()
|
private void AddSubject()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewName)) { ValidationMessage = "Name erforderlich."; return; }
|
if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; }
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() });
|
_subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() });
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
ValidationMessage = ex.Message;
|
NewNameError = ex.Message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
NewName = ""; NewShort = ""; ValidationMessage = "";
|
NewName = ""; NewShort = ""; NewNameError = "";
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +156,10 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
ValidationMessage = ex.Message;
|
NewNameError = ex.Message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ValidationMessage = "";
|
NewNameError = "";
|
||||||
if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
|
if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
}
|
}
|
||||||
@@ -362,7 +362,8 @@ public partial class GradingKeyTemplateEditItem : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private string _newGrade = "";
|
[ObservableProperty] private string _newGrade = "";
|
||||||
[ObservableProperty] private double _newMinPercent;
|
[ObservableProperty] private double _newMinPercent;
|
||||||
[ObservableProperty] private string _validation = "";
|
[ObservableProperty] private string _newGradeError = "";
|
||||||
|
[ObservableProperty] private string _newMinPercentError = "";
|
||||||
[ObservableProperty] private string _completenessWarning = "";
|
[ObservableProperty] private string _completenessWarning = "";
|
||||||
|
|
||||||
public ObservableCollection<GradingKeyEntryVm> Entries { get; } = [];
|
public ObservableCollection<GradingKeyEntryVm> Entries { get; } = [];
|
||||||
@@ -384,10 +385,15 @@ public partial class GradingKeyTemplateEditItem : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddEntry()
|
private void AddEntry()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewGrade)) { Validation = "Bezeichnung erforderlich."; return; }
|
NewGradeError = ""; NewMinPercentError = "";
|
||||||
if (NewMinPercent is < 0 or > 100) { Validation = "Prozentgrenze muss zwischen 0 und 100 liegen."; return; }
|
var valid = true;
|
||||||
if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
|
|
||||||
{ Validation = "Diese Prozentgrenze existiert bereits."; return; }
|
if (string.IsNullOrWhiteSpace(NewGrade)) { NewGradeError = "Bezeichnung erforderlich."; valid = false; }
|
||||||
|
if (NewMinPercent is < 0 or > 100) { NewMinPercentError = "Muss zwischen 0 und 100 liegen."; valid = false; }
|
||||||
|
else if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
|
||||||
|
{ NewMinPercentError = "Diese Prozentgrenze existiert bereits."; valid = false; }
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
var entry = new GradingKeyEntry { Grade = NewGrade.Trim(), MinPercent = NewMinPercent };
|
var entry = new GradingKeyEntry { Grade = NewGrade.Trim(), MinPercent = NewMinPercent };
|
||||||
_template.Entries.Add(entry);
|
_template.Entries.Add(entry);
|
||||||
@@ -396,7 +402,7 @@ public partial class GradingKeyTemplateEditItem : ObservableObject
|
|||||||
|
|
||||||
Entries.Clear();
|
Entries.Clear();
|
||||||
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
||||||
NewGrade = ""; NewMinPercent = 0; Validation = "";
|
NewGrade = ""; NewMinPercent = 0;
|
||||||
RecomputeCompleteness();
|
RecomputeCompleteness();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -361,7 +361,9 @@ public partial class AddStudentDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _dateOfBirthText = "";
|
[ObservableProperty] private string _dateOfBirthText = "";
|
||||||
[ObservableProperty] private string _selectedGender = "";
|
[ObservableProperty] private string _selectedGender = "";
|
||||||
[ObservableProperty] private string _notes = "";
|
[ObservableProperty] private string _notes = "";
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _lastNameError = "";
|
||||||
|
[ObservableProperty] private string _firstNameError = "";
|
||||||
|
[ObservableProperty] private string _dateOfBirthError = "";
|
||||||
|
|
||||||
public List<string> GenderOptions { get; } = ["", "M – männlich", "W – weiblich", "D – divers"];
|
public List<string> GenderOptions { get; } = ["", "M – männlich", "W – weiblich", "D – divers"];
|
||||||
public List<string> RelationPresets { get; } = ["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"];
|
public List<string> RelationPresets { get; } = ["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"];
|
||||||
@@ -380,8 +382,11 @@ public partial class AddStudentDialogViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(LastName)) { ValidationMessage = "Nachname erforderlich."; return; }
|
LastNameError = ""; FirstNameError = ""; DateOfBirthError = "";
|
||||||
if (string.IsNullOrWhiteSpace(FirstName)) { ValidationMessage = "Vorname erforderlich."; return; }
|
var valid = true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(LastName)) { LastNameError = "Nachname erforderlich."; valid = false; }
|
||||||
|
if (string.IsNullOrWhiteSpace(FirstName)) { FirstNameError = "Vorname erforderlich."; valid = false; }
|
||||||
|
|
||||||
DateOnly? dob = null;
|
DateOnly? dob = null;
|
||||||
if (!string.IsNullOrWhiteSpace(DateOfBirthText))
|
if (!string.IsNullOrWhiteSpace(DateOfBirthText))
|
||||||
@@ -389,11 +394,13 @@ public partial class AddStudentDialogViewModel : ObservableObject
|
|||||||
if (!DateOnly.TryParseExact(DateOfBirthText, "dd.MM.yyyy", null,
|
if (!DateOnly.TryParseExact(DateOfBirthText, "dd.MM.yyyy", null,
|
||||||
System.Globalization.DateTimeStyles.None, out var d))
|
System.Globalization.DateTimeStyles.None, out var d))
|
||||||
{
|
{
|
||||||
ValidationMessage = "Geburtsdatum im Format TT.MM.JJJJ."; return;
|
DateOfBirthError = "Format TT.MM.JJJJ."; valid = false;
|
||||||
}
|
}
|
||||||
dob = d;
|
else dob = d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
Result = new Student
|
Result = new Student
|
||||||
{
|
{
|
||||||
FirstName = FirstName.Trim(),
|
FirstName = FirstName.Trim(),
|
||||||
@@ -456,7 +463,8 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _invalidSinceText = "";
|
[ObservableProperty] private string _invalidSinceText = "";
|
||||||
[ObservableProperty] private string _selectedInvalidReason = "";
|
[ObservableProperty] private string _selectedInvalidReason = "";
|
||||||
[ObservableProperty] private string _invalidReasonDetails = "";
|
[ObservableProperty] private string _invalidReasonDetails = "";
|
||||||
[ObservableProperty] private string _validationMessage = "";
|
[ObservableProperty] private string _nameError = "";
|
||||||
|
[ObservableProperty] private string _invalidSinceError = "";
|
||||||
|
|
||||||
public string DialogTitle => _source is null ? "Kontakt anlegen" : "Kontakt bearbeiten";
|
public string DialogTitle => _source is null ? "Kontakt anlegen" : "Kontakt bearbeiten";
|
||||||
public List<string> RelationPresets { get; } =
|
public List<string> RelationPresets { get; } =
|
||||||
@@ -494,10 +502,13 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
|
NameError = ""; InvalidSinceError = "";
|
||||||
|
var valid = true;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(Name))
|
if (string.IsNullOrWhiteSpace(Name))
|
||||||
{
|
{
|
||||||
ValidationMessage = "Name erforderlich.";
|
NameError = "Name erforderlich.";
|
||||||
return;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DateOnly? invalidSince = null;
|
DateOnly? invalidSince = null;
|
||||||
@@ -506,12 +517,14 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
if (!DateOnly.TryParseExact(InvalidSinceText, "dd.MM.yyyy", null,
|
if (!DateOnly.TryParseExact(InvalidSinceText, "dd.MM.yyyy", null,
|
||||||
System.Globalization.DateTimeStyles.None, out var parsedDate))
|
System.Globalization.DateTimeStyles.None, out var parsedDate))
|
||||||
{
|
{
|
||||||
ValidationMessage = "Ungültig seit im Format TT.MM.JJJJ angeben.";
|
InvalidSinceError = "Format TT.MM.JJJJ.";
|
||||||
return;
|
valid = false;
|
||||||
}
|
}
|
||||||
invalidSince = parsedDate;
|
else invalidSince = parsedDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
Result = new Contact
|
Result = new Contact
|
||||||
{
|
{
|
||||||
Id = _source?.Id ?? Guid.NewGuid(),
|
Id = _source?.Id ?? Guid.NewGuid(),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="{Binding NameLabel}" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="{Binding NameLabel}" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding Name}" PlaceholderText="{Binding NameHint}"/>
|
<TextBox Text="{Binding Name}" PlaceholderText="{Binding NameHint}"/>
|
||||||
|
<TextBlock Text="{Binding NameError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding NameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Fach – AutoComplete aus bekannten Fächern; auch bei Klasse, da eine
|
<!-- Fach – AutoComplete aus bekannten Fächern; auch bei Klasse, da eine
|
||||||
@@ -41,6 +43,8 @@
|
|||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Klassenstufe *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Klassenstufe *" FontSize="12" Opacity="0.7"/>
|
||||||
<NumericUpDown Value="{Binding GradeLevel}" Minimum="1" Maximum="13" FormatString="0"/>
|
<NumericUpDown Value="{Binding GradeLevel}" Minimum="1" Maximum="13" FormatString="0"/>
|
||||||
|
<TextBlock Text="{Binding GradeLevelError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding GradeLevelError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Text="Schuljahr" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Schuljahr" FontSize="12" Opacity="0.7"/>
|
||||||
@@ -70,8 +74,6 @@
|
|||||||
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
|
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
|
||||||
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
|
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
|
||||||
|
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
|||||||
@@ -14,15 +14,14 @@
|
|||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ" x:Name="DateBox"/>
|
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ" x:Name="DateBox"/>
|
||||||
|
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Kommentar (optional)" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Kommentar (optional)" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding Comment}" PlaceholderText="z. B. Stunde 12 – Säure-Base-Reaktion"/>
|
<TextBox Text="{Binding Comment}" PlaceholderText="z. B. Stunde 12 – Säure-Base-Reaktion"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
|||||||
@@ -15,14 +15,16 @@
|
|||||||
<Grid ColumnDefinitions="120,*,90" ColumnSpacing="6">
|
<Grid ColumnDefinitions="120,*,90" ColumnSpacing="6">
|
||||||
<ComboBox Grid.Column="0" ItemsSource="{x:Static vm:GradeCategoryDisplay.Options}"
|
<ComboBox Grid.Column="0" ItemsSource="{x:Static vm:GradeCategoryDisplay.Options}"
|
||||||
SelectedItem="{Binding CategoryName}"/>
|
SelectedItem="{Binding CategoryName}"/>
|
||||||
<TextBox Grid.Column="1" Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
|
<StackPanel Grid.Column="1" Spacing="2">
|
||||||
|
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
<NumericUpDown Grid.Column="2" Value="{Binding Weight}" Minimum="0" Maximum="10"
|
<NumericUpDown Grid.Column="2" Value="{Binding Weight}" Minimum="0" Maximum="10"
|
||||||
Increment="0.1" FormatString="0.#" ShowButtonSpinner="False"
|
Increment="0.1" FormatString="0.#" ShowButtonSpinner="False"
|
||||||
ToolTip.Tip="Gewichtung"/>
|
ToolTip.Tip="Gewichtung"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBox Text="{Binding Note}" PlaceholderText="Notiz (z.B. Hausaufgabenkontrolle)"/>
|
<TextBox Text="{Binding Note}" PlaceholderText="Notiz (z.B. Hausaufgabenkontrolle)"/>
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
<TextBlock Text="Werte pro Schüler unten eintragen. Leere Felder werden nicht gespeichert."
|
<TextBlock Text="Werte pro Schüler unten eintragen. Leere Felder werden nicht gespeichert."
|
||||||
FontSize="11" Opacity="0.5" TextWrapping="Wrap"/>
|
FontSize="11" Opacity="0.5" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding Title}" PlaceholderText="z.B. 1. Klausur Kinetik"/>
|
<TextBox Text="{Binding Title}" PlaceholderText="z.B. 1. Klausur Kinetik"/>
|
||||||
|
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4" IsVisible="{Binding IsDifferentiated}">
|
<StackPanel Spacing="4" IsVisible="{Binding IsDifferentiated}">
|
||||||
@@ -32,6 +34,8 @@
|
|||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
|
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Text="Klausurnummer" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Klausurnummer" FontSize="12" Opacity="0.7"/>
|
||||||
@@ -41,6 +45,8 @@
|
|||||||
<StackPanel Grid.Column="4" Spacing="4">
|
<StackPanel Grid.Column="4" Spacing="4">
|
||||||
<TextBlock Text="Rückgabedatum" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Rückgabedatum" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding ReturnedAtText}" PlaceholderText="TT.MM.JJJJ"/>
|
<TextBox Text="{Binding ReturnedAtText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding ReturnedAtTextError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding ReturnedAtTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -118,9 +124,6 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
|
|
||||||
<Separator Margin="0,4"/>
|
<Separator Margin="0,4"/>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
|||||||
@@ -128,11 +128,17 @@
|
|||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Text="Abschnitt abschließen" FontSize="12" FontWeight="SemiBold"/>
|
<TextBlock Text="Abschnitt abschließen" FontSize="12" FontWeight="SemiBold"/>
|
||||||
<Grid ColumnDefinitions="*,100" ColumnSpacing="6">
|
<Grid ColumnDefinitions="*,100" ColumnSpacing="6">
|
||||||
<TextBox Grid.Column="0" Text="{Binding NewSectionLabel}" PlaceholderText="Bezeichnung"/>
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
<TextBox Grid.Column="1" Text="{Binding NewSectionEndDateText}" PlaceholderText="TT.MM.JJJJ"/>
|
<TextBox Text="{Binding NewSectionLabel}" PlaceholderText="Bezeichnung"/>
|
||||||
|
<TextBlock Text="{Binding NewSectionLabelError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding NewSectionLabelError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="2">
|
||||||
|
<TextBox Text="{Binding NewSectionEndDateText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding NewSectionEndDateTextError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding NewSectionEndDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="{Binding SectionValidationMessage}" Foreground="Red" FontSize="11"
|
|
||||||
IsVisible="{Binding SectionValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
<Button Content="Abschnitt für alle Schüler abschließen" Command="{Binding CloseSectionCommand}"
|
<Button Content="Abschnitt für alle Schüler abschließen" Command="{Binding CloseSectionCommand}"
|
||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -24,15 +24,21 @@
|
|||||||
<Grid ColumnDefinitions="120,*,90,90" ColumnSpacing="6">
|
<Grid ColumnDefinitions="120,*,90,90" ColumnSpacing="6">
|
||||||
<ComboBox Grid.Column="0" ItemsSource="{x:Static vm:GradeCategoryDisplay.Options}"
|
<ComboBox Grid.Column="0" ItemsSource="{x:Static vm:GradeCategoryDisplay.Options}"
|
||||||
SelectedItem="{Binding CategoryName}"/>
|
SelectedItem="{Binding CategoryName}"/>
|
||||||
<TextBox Grid.Column="1" Text="{Binding Value}" PlaceholderText="Wert (z.B. 2 oder 11)"/>
|
<StackPanel Grid.Column="1" Spacing="2">
|
||||||
<TextBox Grid.Column="2" Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
|
<TextBox Text="{Binding Value}" PlaceholderText="Wert (z.B. 2 oder 11)"/>
|
||||||
|
<TextBlock Text="{Binding ValueError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding ValueError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="2">
|
||||||
|
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
<NumericUpDown Grid.Column="3" Value="{Binding Weight}" Minimum="0" Maximum="10"
|
<NumericUpDown Grid.Column="3" Value="{Binding Weight}" Minimum="0" Maximum="10"
|
||||||
Increment="0.1" FormatString="0.#" ShowButtonSpinner="False"
|
Increment="0.1" FormatString="0.#" ShowButtonSpinner="False"
|
||||||
ToolTip.Tip="Gewichtung"/>
|
ToolTip.Tip="Gewichtung"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBox Text="{Binding Note}" PlaceholderText="Notiz (optional)" FontSize="12"/>
|
<TextBox Text="{Binding Note}" PlaceholderText="Notiz (optional)" FontSize="12"/>
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="11"
|
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding CreatedAtDisplay}" FontSize="11" Opacity="0.5"
|
<TextBlock Grid.Column="0" Text="{Binding CreatedAtDisplay}" FontSize="11" Opacity="0.5"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
|||||||
@@ -9,12 +9,14 @@
|
|||||||
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
|
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
|
||||||
xmlns:vset="clr-namespace:LehrerApp.Desktop.Views.Settings"
|
xmlns:vset="clr-namespace:LehrerApp.Desktop.Views.Settings"
|
||||||
xmlns:vmset="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
|
xmlns:vmset="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
|
||||||
|
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
|
||||||
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
x:Class="LehrerApp.Desktop.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
Title="LehrerApp"
|
Title="LehrerApp"
|
||||||
Width="1280" Height="800"
|
Width="1280" Height="800"
|
||||||
MinWidth="900" MinHeight="600">
|
MinWidth="900" MinHeight="600">
|
||||||
|
|
||||||
|
<Panel>
|
||||||
<!--
|
<!--
|
||||||
Avalonia 12: DrawerPage ersetzt die selbstgebaute Sidebar.
|
Avalonia 12: DrawerPage ersetzt die selbstgebaute Sidebar.
|
||||||
DrawerBreakpointWidth="900" → Sidebar ab 900px dauerhaft sichtbar,
|
DrawerBreakpointWidth="900" → Sidebar ab 900px dauerhaft sichtbar,
|
||||||
@@ -189,4 +191,32 @@
|
|||||||
</DrawerPage.Drawer>
|
</DrawerPage.Drawer>
|
||||||
|
|
||||||
</DrawerPage>
|
</DrawerPage>
|
||||||
|
|
||||||
|
<!-- Toast-Benachrichtigungen (13.2.3): schweben über der aktuellen Seite, unten rechts. -->
|
||||||
|
<ItemsControl ItemsSource="{Binding Toasts}"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,20,20" IsHitTestVisible="False">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Spacing="8"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.Styles>
|
||||||
|
<Style Selector="Border.toast">
|
||||||
|
<Setter Property="Background" Value="#323232"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.toast.error">
|
||||||
|
<Setter Property="Background" Value="#B3261E"/>
|
||||||
|
</Style>
|
||||||
|
</ItemsControl.Styles>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="svc:ToastItem">
|
||||||
|
<Border Classes="toast" Classes.error="{Binding IsError}"
|
||||||
|
CornerRadius="8" Padding="14,10" MaxWidth="360">
|
||||||
|
<TextBlock Text="{Binding Message}" Foreground="White" FontSize="13" TextWrapping="Wrap"/>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Panel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -28,15 +28,20 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<TextBlock Text="{Binding Validation}" Foreground="Red" FontSize="11"
|
|
||||||
IsVisible="{Binding Validation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,90,8,Auto">
|
<Grid ColumnDefinitions="*,8,90,8,Auto">
|
||||||
<TextBox Grid.Column="0" Text="{Binding NewGrade}"
|
<StackPanel Grid.Column="0" Spacing="2">
|
||||||
PlaceholderText="Note (z.B. 2 oder 11)" FontSize="12"/>
|
<TextBox Text="{Binding NewGrade}"
|
||||||
<NumericUpDown Grid.Column="2" Value="{Binding NewMinPercent}" Minimum="0" Maximum="100"
|
PlaceholderText="Note (z.B. 2 oder 11)" FontSize="12"/>
|
||||||
FormatString="0.##" ShowButtonSpinner="False" FontSize="12"
|
<TextBlock Text="{Binding NewGradeError}" Foreground="Red" FontSize="11"
|
||||||
ToolTip.Tip="Mindest-Prozentgrenze"/>
|
IsVisible="{Binding NewGradeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="2">
|
||||||
|
<NumericUpDown Value="{Binding NewMinPercent}" Minimum="0" Maximum="100"
|
||||||
|
FormatString="0.##" ShowButtonSpinner="False" FontSize="12"
|
||||||
|
ToolTip.Tip="Mindest-Prozentgrenze"/>
|
||||||
|
<TextBlock Text="{Binding NewMinPercentError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding NewMinPercentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
<Button Grid.Column="4" Content="+" Command="{Binding AddEntryCommand}"/>
|
<Button Grid.Column="4" Content="+" Command="{Binding AddEntryCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -47,8 +47,8 @@
|
|||||||
<Button Grid.Column="4" Content="Hinzufügen"
|
<Button Grid.Column="4" Content="Hinzufügen"
|
||||||
Command="{Binding AddSubjectCommand}"/>
|
Command="{Binding AddSubjectCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
<TextBlock Text="{Binding NewNameError}" Foreground="Red" FontSize="12"
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding NewNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -213,8 +213,8 @@
|
|||||||
<Button Grid.Column="4" Content="Anlegen"
|
<Button Grid.Column="4" Content="Anlegen"
|
||||||
Command="{Binding AddGradingKeyTemplateCommand}"/>
|
Command="{Binding AddGradingKeyTemplateCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="{Binding TemplateValidationMessage}" Foreground="Red" FontSize="12"
|
<TextBlock Text="{Binding NewTemplateNameError}" Foreground="Red" FontSize="12"
|
||||||
IsVisible="{Binding TemplateValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding NewTemplateNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,14 @@
|
|||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Nachname *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Nachname *" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding LastName}" PlaceholderText="Mustermann" x:Name="LastNameBox"/>
|
<TextBox Text="{Binding LastName}" PlaceholderText="Mustermann" x:Name="LastNameBox"/>
|
||||||
|
<TextBlock Text="{Binding LastNameError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding LastNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Text="Vorname *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Vorname *" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding FirstName}" PlaceholderText="Max"/>
|
<TextBox Text="{Binding FirstName}" PlaceholderText="Max"/>
|
||||||
|
<TextBlock Text="{Binding FirstNameError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding FirstNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -31,6 +35,8 @@
|
|||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Geburtsdatum" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Geburtsdatum" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding DateOfBirthText}" PlaceholderText="TT.MM.JJJJ"/>
|
<TextBox Text="{Binding DateOfBirthText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding DateOfBirthError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding DateOfBirthError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Text="Geschlecht" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Geschlecht" FontSize="12" Opacity="0.7"/>
|
||||||
@@ -99,9 +105,6 @@
|
|||||||
AcceptsReturn="True" Height="70" TextWrapping="Wrap"/>
|
AcceptsReturn="True" Height="70" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Validierungsfehler -->
|
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Name *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Name *" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox x:Name="NameBox" Text="{Binding Name}" PlaceholderText="Name"/>
|
<TextBox x:Name="NameBox" Text="{Binding Name}" PlaceholderText="Name"/>
|
||||||
|
<TextBlock Text="{Binding NameError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding NameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Text="Beziehung" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Beziehung" FontSize="12" Opacity="0.7"/>
|
||||||
@@ -56,6 +58,8 @@
|
|||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Ungültig seit *" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Ungültig seit *" FontSize="12" Opacity="0.7"/>
|
||||||
<TextBox Text="{Binding InvalidSinceText}" PlaceholderText="TT.MM.JJJJ"/>
|
<TextBox Text="{Binding InvalidSinceText}" PlaceholderText="TT.MM.JJJJ"/>
|
||||||
|
<TextBlock Text="{Binding InvalidSinceError}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding InvalidSinceError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Spacing="4">
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
<TextBlock Text="Grund" FontSize="12" Opacity="0.7"/>
|
<TextBlock Text="Grund" FontSize="12" Opacity="0.7"/>
|
||||||
@@ -72,8 +76,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
|
|
||||||
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Tests;
|
||||||
|
|
||||||
|
public sealed class AppLoggerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Konstruktor_LegtLogVerzeichnisAn()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
|
||||||
|
_ = new AppLogger(temp.Path);
|
||||||
|
|
||||||
|
Assert.True(Directory.Exists(Path.Combine(temp.Path, "logs")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Info_SchreibtZeileInDieHeutigeLogdatei()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var logger = new AppLogger(temp.Path);
|
||||||
|
|
||||||
|
logger.Info("Anwendung gestartet.");
|
||||||
|
|
||||||
|
var expectedFile = Path.Combine(temp.Path, "logs", $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log");
|
||||||
|
Assert.True(File.Exists(expectedFile));
|
||||||
|
var content = File.ReadAllText(expectedFile);
|
||||||
|
Assert.Contains("[INFO ]", content);
|
||||||
|
Assert.Contains("Anwendung gestartet.", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Error_MitException_SchreibtStapelverfolgungMit()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var logger = new AppLogger(temp.Path);
|
||||||
|
|
||||||
|
try { throw new InvalidOperationException("Testfehler"); }
|
||||||
|
catch (Exception ex) { logger.Error("Etwas ist schiefgelaufen.", ex); }
|
||||||
|
|
||||||
|
var content = File.ReadAllText(Path.Combine(temp.Path, "logs", $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log"));
|
||||||
|
Assert.Contains("[ERROR]", content);
|
||||||
|
Assert.Contains("Testfehler", content);
|
||||||
|
Assert.Contains("InvalidOperationException", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MehrereEintraege_WerdenAlleAngehaengt()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var logger = new AppLogger(temp.Path);
|
||||||
|
|
||||||
|
logger.Info("Erster Eintrag");
|
||||||
|
logger.Warn("Zweiter Eintrag");
|
||||||
|
logger.Error("Dritter Eintrag");
|
||||||
|
|
||||||
|
var lines = File.ReadAllLines(Path.Combine(temp.Path, "logs", $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log"));
|
||||||
|
Assert.Equal(3, lines.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Konstruktor_LoeschtLogdateienAelterAlsDieAufbewahrungsfrist()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var logDir = Path.Combine(temp.Path, "logs");
|
||||||
|
Directory.CreateDirectory(logDir);
|
||||||
|
|
||||||
|
var oldFile = Path.Combine(logDir, "lehrerapp-2000-01-01.log");
|
||||||
|
var recentFile = Path.Combine(logDir, $"lehrerapp-{DateTime.Now:yyyy-MM-dd}.log");
|
||||||
|
File.WriteAllText(oldFile, "alt");
|
||||||
|
File.WriteAllText(recentFile, "aktuell");
|
||||||
|
File.SetLastWriteTime(oldFile, DateTime.Now.AddDays(-30));
|
||||||
|
|
||||||
|
_ = new AppLogger(temp.Path);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(oldFile));
|
||||||
|
Assert.True(File.Exists(recentFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Konstruktor_BehaeltLogdateienInnerhalbDerAufbewahrungsfrist()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var logDir = Path.Combine(temp.Path, "logs");
|
||||||
|
Directory.CreateDirectory(logDir);
|
||||||
|
|
||||||
|
var recentButOldFile = Path.Combine(logDir, "lehrerapp-recent.log");
|
||||||
|
File.WriteAllText(recentButOldFile, "vor 5 Tagen");
|
||||||
|
File.SetLastWriteTime(recentButOldFile, DateTime.Now.AddDays(-5));
|
||||||
|
|
||||||
|
_ = new AppLogger(temp.Path);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(recentButOldFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempAppData : IDisposable
|
||||||
|
{
|
||||||
|
public string Path { get; } = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"lehrerapp-logger-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
public TempAppData() => Directory.CreateDirectory(Path);
|
||||||
|
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -491,10 +491,43 @@ Fächer- und Kompetenzverwaltung existiert bereits in
|
|||||||
`.ToUniversalTime()` auf beiden Seiten vor dem Vergleich.
|
`.ToUniversalTime()` auf beiden Seiten vor dem Vergleich.
|
||||||
|
|
||||||
### 13.2 Fehlerbehandlung & Logging
|
### 13.2 Fehlerbehandlung & Logging
|
||||||
- [ ] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz.
|
- [x] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz —
|
||||||
- [ ] **13.2.2** Logging in Datei im App-Datenverzeichnis, mit Rotation.
|
[GlobalExceptionHandler.cs](LehrerApp.Desktop/Services/GlobalExceptionHandler.cs).
|
||||||
- [ ] **13.2.3** Einheitliche Benachrichtigungen (Toast/Snackbar) für Erfolg und Fehler.
|
`Dispatcher.UIThread.UnhandledException` fängt Fehler aus Befehlen/Ereignis-Handlern auf
|
||||||
- [ ] **13.2.4** Validierungsmeldungen einheitlich an den Eingabefeldern statt in Sammel-Labels.
|
dem UI-Thread ab, protokolliert sie und setzt `Handled = true` — die App stürzt dabei
|
||||||
|
nachweislich nicht ab (per Headless-Test verifiziert: Fehler in einem Dispatcher-Callback
|
||||||
|
wird geloggt + als Toast angezeigt, App läuft weiter). `AppDomain.UnhandledException` und
|
||||||
|
`TaskScheduler.UnobservedTaskException` sind Sicherheitsnetze für Fehler außerhalb des
|
||||||
|
UI-Threads — dort kann ein bereits "IsTerminating"-Fehler nicht mehr verhindert werden,
|
||||||
|
wird aber vollständig protokolliert.
|
||||||
|
- [x] **13.2.2** Logging in Datei im App-Datenverzeichnis, mit Rotation —
|
||||||
|
[AppLogger.cs](LehrerApp.Core/Services/AppLogger.cs), tägliche Datei unter
|
||||||
|
`<AppData>/LehrerApp/logs/`, Dateien älter als 14 Tage werden beim Start gelöscht.
|
||||||
|
6 Tests in [AppLoggerTests.cs](LehrerApp.Tests/AppLoggerTests.cs).
|
||||||
|
- [x] **13.2.3** Einheitliche Benachrichtigungen (Toast/Snackbar) für Erfolg und Fehler —
|
||||||
|
[NotificationService.cs](LehrerApp.Desktop/Services/NotificationService.cs), Overlay unten
|
||||||
|
rechts in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml), blendet sich nach
|
||||||
|
3 s (Erfolg) bzw. 6 s (Fehler) automatisch aus. Aktuell an den globalen Exception-Handler
|
||||||
|
angebunden; bestehende Dialoge zeigen Erfolg/Fehler weiterhin über ihre eigenen
|
||||||
|
Statuszeilen (`StatusMessage`/`ValidationMessage`) — eine Umstellung dieser bestehenden
|
||||||
|
Stellen auf Toasts wäre ein eigener, größerer Umbau über viele Dateien hinweg und ist
|
||||||
|
bewusst nicht Teil dieser Aufgabe.
|
||||||
|
- [x] **13.2.4** Validierungsmeldungen einheitlich an den Eingabefeldern statt in Sammel-Labels.
|
||||||
|
Alle Dialoge mit Formularfeldern umgestellt: `AddStudentDialog`, `ContactEditDialog`,
|
||||||
|
`AddGroupDialog`, `StudentGradesDialog` (`GradeEditItem`), `CollectiveGradeDialog`,
|
||||||
|
`ExamDialog`, `AddSessionDialog`, `ParticipationWizardDialog` (Abschnitt-Formular),
|
||||||
|
Einstellungen (Fach anlegen, Notenschlüssel-Vorlage anlegen, Notenschlüssel-Editor).
|
||||||
|
Muster: pro Feld eine `{Feld}Error`-Property, `Save()` leert alle Fehler-Properties am
|
||||||
|
Anfang, sammelt alle Verstöße über eine lokale `valid`-Variable statt beim ersten Fehler
|
||||||
|
zurückzukehren, und bricht erst am Ende mit `if (!valid) return;` ab — so werden mehrere
|
||||||
|
Fehler gleichzeitig angezeigt. Bewusst als Sammel-Meldung belassen, wo die Meldung sich auf
|
||||||
|
eine Kombination mehrerer Felder bezieht statt auf ein einzelnes Feld: `GradingKeyValidation`
|
||||||
|
(Notenschlüssel-Tabelle als Ganzes), `GradingSchemeEditItem.ValidationMessage`
|
||||||
|
(Summe der drei Prozent-Felder muss 100 ergeben), `ReportGradeRow.ValidationMessage`
|
||||||
|
(Override-Wert + Begründung gehören zusammen), `CatalogValidation`
|
||||||
|
(Kompetenzkatalog-Panel: Fachauswahl, Bereichsname und JSON-Import teilen sich einen
|
||||||
|
Status-Bereich), `AddStudentToGroupDialogViewModel.ValidationMessage` (einzige Prüfung
|
||||||
|
"Schüler ausgewählt?", kein Textfeld zum Andocken vorhanden).
|
||||||
|
|
||||||
### 13.3 Datensicherheit
|
### 13.3 Datensicherheit
|
||||||
- [ ] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10).
|
- [ ] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10).
|
||||||
|
|||||||
Reference in New Issue
Block a user