1962 lines
80 KiB
C#
1962 lines
80 KiB
C#
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using LehrerApp.Core.Services;
|
||
using LehrerApp.Data;
|
||
using LehrerApp.Desktop.Services;
|
||
using LehrerApp.Desktop.ViewModels.Planning;
|
||
using LehrerApp.Sync;
|
||
using LehrerApp.Sync.Crypto;
|
||
using System.Collections.ObjectModel;
|
||
using System.Globalization;
|
||
using System.Security.Cryptography;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||
|
||
/// <summary>
|
||
/// Reihenfolge MUSS exakt der Reihenfolge der ContentPage-Elemente in SettingsView.axaml
|
||
/// entsprechen (ActiveTabIndex bindet per Index, nicht per Name) — beim Umsortieren eines
|
||
/// Tabs in der XAML immer auch hier den Enum-Wert verschieben.
|
||
/// </summary>
|
||
public enum SettingsTab
|
||
{
|
||
Subjects = 0,
|
||
ShorthandCodes = 1,
|
||
Competencies = 2,
|
||
GradingKeyTemplates = 3,
|
||
GradingScheme = 4,
|
||
LetterTemplates = 5,
|
||
Holidays = 6,
|
||
PeriodSchedule = 7,
|
||
SupervisionDuties = 8,
|
||
Security = 9,
|
||
Privacy = 10,
|
||
Sync = 11,
|
||
Ai = 12,
|
||
WebUntis = 13,
|
||
Appearance = 14,
|
||
Trash = 15,
|
||
}
|
||
|
||
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
|
||
|
||
public partial class SettingsViewModel : ObservableObject
|
||
{
|
||
private readonly ISubjectRepository _subjects;
|
||
private readonly ICompetencyDomainRepository _domainRepo;
|
||
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
|
||
private readonly IGradingSchemeRepository _gradingSchemes;
|
||
private readonly GradingService _grading;
|
||
private readonly BackupService _backups;
|
||
private readonly DatabaseEncryptionService _dbEncryption;
|
||
private readonly AppLockService _appLock;
|
||
private readonly LiteDbContext _dbContext;
|
||
private readonly PrivacySettingsService _privacy;
|
||
private readonly IDocumentationRepository _documentation;
|
||
private readonly IStudentRepository _students;
|
||
private readonly IShorthandCodeRepository _shorthandCodes;
|
||
private readonly LetterTemplateService _letterTemplates;
|
||
|
||
[ObservableProperty] private int _activeTabIndex;
|
||
|
||
// ── Fächer ────────────────────────────────────────────────────────────────
|
||
|
||
[ObservableProperty] private string _newName = "";
|
||
[ObservableProperty] private string _newShort = "";
|
||
[ObservableProperty] private string _newNameError = "";
|
||
|
||
public ObservableCollection<SubjectListItem> Subjects { get; } = [];
|
||
|
||
// ── Kürzel-Katalog (Stundenverlaufsplan, 4.2.2) ──────────────────────────
|
||
|
||
[ObservableProperty] private string _newShorthandCode = "";
|
||
[ObservableProperty] private string _newShorthandLabel = "";
|
||
[ObservableProperty] private string _newShorthandCodeError = "";
|
||
|
||
public ObservableCollection<ShorthandCodeListItem> ShorthandCodes { get; } = [];
|
||
|
||
// ── Kompetenzkatalog ──────────────────────────────────────────────────────
|
||
|
||
[ObservableProperty] private SubjectListItem? _catalogSubject;
|
||
[ObservableProperty] private int _catalogGradeLevel = 10;
|
||
[ObservableProperty] private string _newDomainName = "";
|
||
[ObservableProperty] private string _newDomainCode = "";
|
||
[ObservableProperty] private string _catalogValidation = "";
|
||
|
||
public ObservableCollection<DomainEditItem> Domains { get; } = [];
|
||
|
||
// ── Notenschlüssel-Vorlagen (1.3.2) ──────────────────────────────────────
|
||
|
||
[ObservableProperty] private string _newTemplateName = "";
|
||
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 1–6";
|
||
[ObservableProperty] private string _newTemplateNameError = "";
|
||
|
||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
||
|
||
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
|
||
|
||
[ObservableProperty] private string _letterTemplateStatus = "";
|
||
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
|
||
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
|
||
LetterTemplateService.SupportedPlaceholders;
|
||
|
||
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
|
||
|
||
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
||
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
|
||
|
||
// ── Sicherheit: Datensicherung (13.3.1/13.3.2) ───────────────────────────
|
||
|
||
[ObservableProperty] private string _backupStatus = "";
|
||
public ObservableCollection<BackupListItem> Backups { get; } = [];
|
||
|
||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
|
||
public Func<BackupListItem, Task<bool>>? OnConfirmRestore { get; set; }
|
||
|
||
// ── Sicherheit: Datenbank-Verschlüsselung (13.3.4) ───────────────────────
|
||
|
||
[ObservableProperty] private bool _isDbEncrypted;
|
||
[ObservableProperty] private string _currentDbPassword = "";
|
||
[ObservableProperty] private string _newDbPassword = "";
|
||
[ObservableProperty] private string _newDbPasswordConfirm = "";
|
||
[ObservableProperty] private string _dbPasswordError = "";
|
||
|
||
public string DbEncryptionStatusLabel => IsDbEncrypted ? "verschlüsselt" : "nicht verschlüsselt";
|
||
|
||
partial void OnIsDbEncryptedChanged(bool value) => OnPropertyChanged(nameof(DbEncryptionStatusLabel));
|
||
|
||
// ── Sicherheit: App-Sperre nach Inaktivität (13.3.5) ─────────────────────
|
||
|
||
[ObservableProperty] private bool _appLockEnabled;
|
||
[ObservableProperty] private int _appLockTimeoutMinutes = 10;
|
||
[ObservableProperty] private string _appLockNewPassword = "";
|
||
[ObservableProperty] private string _appLockNewPasswordConfirm = "";
|
||
[ObservableProperty] private string _appLockPasswordError = "";
|
||
[ObservableProperty] private string _appLockStatus = "";
|
||
|
||
public bool AppLockHasPassword => _appLock.HasPassword;
|
||
|
||
/// Vom Code-Behind gesetzt: aktualisiert den laufenden Inaktivitäts-Timer sofort,
|
||
/// ohne dass die App neu gestartet werden muss.
|
||
public Action? OnAppLockChanged { get; set; }
|
||
|
||
// ── Datenschutz: Löschfristen (5.4.2) ────────────────────────────────────
|
||
|
||
[ObservableProperty] private int _retentionYears = 3;
|
||
[ObservableProperty] private string _retentionStatus = "";
|
||
|
||
public ObservableCollection<ExpiredDocumentItem> ExpiredDocuments { get; } = [];
|
||
|
||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
|
||
public Func<ExpiredDocumentItem, Task<bool>>? OnConfirmHardDelete { get; set; }
|
||
|
||
// ── Ferien & Feiertage (4.3.5, aus dem Stundenplan hierher verschoben) ───
|
||
|
||
[ObservableProperty] private string _selectedStateName = "";
|
||
[ObservableProperty] private string _newHolidayName = "";
|
||
[ObservableProperty] private string _newHolidayStartText = "";
|
||
[ObservableProperty] private string _newHolidayEndText = "";
|
||
[ObservableProperty] private string _holidayNameError = "";
|
||
[ObservableProperty] private string _holidayDateError = "";
|
||
[ObservableProperty] private string _schoolName = "";
|
||
[ObservableProperty] private string _schoolStreet = "";
|
||
[ObservableProperty] private string _schoolPostalCode = "";
|
||
[ObservableProperty] private string _schoolCity = "";
|
||
[ObservableProperty] private string _schoolNameError = "";
|
||
[ObservableProperty] private string _schoolStreetError = "";
|
||
[ObservableProperty] private string _schoolPostalCodeError = "";
|
||
[ObservableProperty] private string _schoolCityError = "";
|
||
[ObservableProperty] private string _schoolLocationStatus = "";
|
||
[ObservableProperty] private string _resolvedSchoolAddress = "";
|
||
|
||
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
|
||
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
|
||
|
||
// ── Stundenraster: Uhrzeiten je Einzelstunde (4.2.2 Nachtrag) ────────────
|
||
|
||
[ObservableProperty] private string _periodTimesError = "";
|
||
[ObservableProperty] private string _periodTimesStatus = "";
|
||
|
||
public ObservableCollection<PeriodTimeEditItem> PeriodTimes { get; } = [];
|
||
|
||
// ── Aufsichten: wiederkehrende Pausenaufsicht (4.3 Nachtrag) ─────────────
|
||
|
||
[ObservableProperty] private string _newDutyWeekdayName = WeekdayDisplay.Options[0];
|
||
[ObservableProperty] private int _newDutyAfterPeriod;
|
||
[ObservableProperty] private string _newDutyLocation = "";
|
||
[ObservableProperty] private string _newDutyError = "";
|
||
|
||
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
|
||
public ObservableCollection<SupervisionDutyItem> SupervisionDuties { get; } = [];
|
||
|
||
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
|
||
|
||
[ObservableProperty] private bool _aiEnabled;
|
||
[ObservableProperty] private string _aiUsername = "";
|
||
[ObservableProperty] private string _aiPassword = "";
|
||
[ObservableProperty] private string _aiLoginError = "";
|
||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||
[ObservableProperty] private string _aiBalanceDisplay = "";
|
||
|
||
// ── WebUntis-iCal-Abgleich (Nutzer-Feedback) ──────────────────────────────
|
||
|
||
[ObservableProperty] private bool _untisEnabled;
|
||
[ObservableProperty] private bool _untisIsConfigured;
|
||
[ObservableProperty] private string _untisIcalUrlInput = "";
|
||
[ObservableProperty] private string _untisUrlError = "";
|
||
[ObservableProperty] private string _untisStatusDisplay = "";
|
||
[ObservableProperty] private bool _untisFetchBusy;
|
||
[ObservableProperty] private bool _untisApiIsConfigured;
|
||
[ObservableProperty] private string _untisSchool = "";
|
||
[ObservableProperty] private string _untisHost = "";
|
||
[ObservableProperty] private string _untisUsername = "";
|
||
[ObservableProperty] private string _untisPassword = "";
|
||
[ObservableProperty] private string _untisApiStatus = "";
|
||
[ObservableProperty] private bool _untisApiBusy;
|
||
public Func<Task>? OnReviewUntisMapping { get; set; }
|
||
|
||
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
|
||
|
||
[ObservableProperty] private bool _annualPlanIsConfigured;
|
||
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
|
||
[ObservableProperty] private string _annualPlanUrlError = "";
|
||
[ObservableProperty] private string _annualPlanStatusDisplay = "";
|
||
[ObservableProperty] private bool _annualPlanFetchBusy;
|
||
|
||
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
|
||
|
||
[ObservableProperty] private string _syncServerUrl = "";
|
||
[ObservableProperty] private string _syncUsername = "";
|
||
[ObservableProperty] private string _syncPassword = "";
|
||
[ObservableProperty] private string _syncLoginError = "";
|
||
[ObservableProperty] private bool _syncIsLoggedIn;
|
||
[ObservableProperty] private string _syncConnectionStatus = "";
|
||
[ObservableProperty] private string _syncForceResyncStatus = "";
|
||
[ObservableProperty] private bool _syncForceResyncBusy;
|
||
[ObservableProperty] private bool _syncKeyWasRegenerated;
|
||
[ObservableProperty] private string _recoveryCode = "";
|
||
[ObservableProperty] private string _recoveryCodeInput = "";
|
||
[ObservableProperty] private string _recoveryStatus = "";
|
||
[ObservableProperty] private bool _recoveryBusy;
|
||
|
||
/// Vom Code-Behind gesetzt: lässt den Nutzer die Wiederherstellungsdatei selbst speichern
|
||
/// (z.B. USB-Stick, eigene Cloud). Liefert false bei Abbruch.
|
||
public Func<string, Task<bool>>? OnSaveRecoveryFile { get; set; }
|
||
/// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei
|
||
/// auswählen. Liefert null bei Abbruch.
|
||
public Func<Task<string?>>? OnPickRecoveryFile { get; set; }
|
||
/// Vom Code-Behind gesetzt: bestätigt vor dem Überschreiben des aktuellen Sync-Schlüssels
|
||
/// dieses Geräts (die App startet danach neu, siehe RedeemRecoveryCode).
|
||
public Func<Task<bool>>? OnConfirmRecoveryRestore { get; set; }
|
||
|
||
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
|
||
|
||
// ── Geräte-Pairing (10.3.1: Schlüsselübertragung auf ein zweites Gerät) ───
|
||
//
|
||
// SnapshotService ist nur registriert, wenn bereits eine Server-URL konfiguriert ist (siehe
|
||
// AppBootstrapper) — daher optional/nullable statt eines Pflicht-Konstruktorparameters.
|
||
|
||
[ObservableProperty] private string _pairingCode = "";
|
||
[ObservableProperty] private string _pairingCodeInput = "";
|
||
[ObservableProperty] private string _pairingStatus = "";
|
||
[ObservableProperty] private bool _pairingBusy;
|
||
|
||
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog, bevor die lokale Datenbank durch
|
||
/// den Stand des anderen Geräts ersetzt wird.
|
||
public Func<Task<bool>>? OnConfirmPairingRestore { get; set; }
|
||
|
||
/// Vom Code-Behind gesetzt: zeigt einen Fehlerdialog, den der Nutzer aktiv wegklicken muss —
|
||
/// notwendig, weil RedeemPairingCode() nach einem Fehlschlag sofort neu startet (_dbContext ist
|
||
/// zu dem Zeitpunkt bereits disposed, siehe dort) und PairingStatus dadurch sonst nie sichtbar
|
||
/// gerendert würde.
|
||
public Func<string, Task>? OnShowPairingError { get; set; }
|
||
|
||
// ── Darstellung (12.4) ──────────────────────────────────────────────────────
|
||
|
||
[ObservableProperty] private AppTheme _selectedTheme;
|
||
public string[] ThemeOptions { get; } = AppThemeDisplay.Options;
|
||
public string SelectedThemeName
|
||
{
|
||
get => AppThemeDisplay.Label(SelectedTheme);
|
||
set => SelectedTheme = AppThemeDisplay.FromLabel(value);
|
||
}
|
||
|
||
/// Vom Code-Behind gesetzt: wendet die Theme-Variante sofort an (Application.Current, siehe
|
||
/// App.ApplyTheme) — ViewModels fassen Avalonia-Framework-Typen nicht direkt an, gleiches
|
||
/// Muster wie die übrigen Code-Behind-Hooks in dieser Klasse.
|
||
public Action<AppTheme>? OnThemeChanged { get; set; }
|
||
|
||
partial void OnSelectedThemeChanged(AppTheme value)
|
||
{
|
||
_appearance.Save(value);
|
||
OnThemeChanged?.Invoke(value);
|
||
}
|
||
|
||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||
|
||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||
private readonly SchoolWeatherService? _schoolWeather;
|
||
private readonly PeriodScheduleService _periodSchedule;
|
||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||
private readonly AiSettingsService _aiSettings;
|
||
private readonly AiPlanningService _aiPlanning;
|
||
private readonly WebUntisSettingsService _untisSettings;
|
||
private readonly WebUntisIntegrationService? _untisIntegration;
|
||
private readonly UntisSyncService? _untisSync;
|
||
private readonly AnnualPlanSettingsService _annualPlanSettings;
|
||
private readonly AnnualPlanSyncService? _annualPlanSync;
|
||
private readonly SyncSettingsService _syncSettings;
|
||
private readonly SyncAuthService _syncAuth;
|
||
private readonly EventQueue _eventQueue;
|
||
private readonly SyncEngine? _syncEngine;
|
||
private readonly SnapshotService? _snapshotService;
|
||
private readonly SyncKeyRecoveryService _syncKeyRecovery;
|
||
private readonly AppearanceSettingsService _appearance;
|
||
private readonly CompetencyCatalogImportService _catalogImport;
|
||
private readonly AppLogger _logger;
|
||
|
||
public TrashViewModel TrashTab { get; }
|
||
|
||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
|
||
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
|
||
IDocumentationRepository documentation, IStudentRepository students,
|
||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||
WebUntisSettingsService untisSettings,
|
||
AnnualPlanSettingsService annualPlanSettings,
|
||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
||
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
|
||
AppearanceSettingsService appearance, TrashViewModel trashTab,
|
||
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
|
||
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
|
||
SchoolWeatherService? schoolWeather = null, WebUntisIntegrationService? untisIntegration = null)
|
||
{
|
||
_logger = logger;
|
||
_syncKeyRecovery = syncKeyRecovery;
|
||
SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated;
|
||
_appearance = appearance;
|
||
_selectedTheme = appearance.Load();
|
||
TrashTab = trashTab;
|
||
_subjects = subjects;
|
||
_domainRepo = domainRepo;
|
||
_gradingKeyTemplates = gradingKeyTemplates;
|
||
_gradingSchemes = gradingSchemes;
|
||
_grading = grading;
|
||
_backups = backups;
|
||
_dbEncryption = dbEncryption;
|
||
_appLock = appLock;
|
||
_dbContext = dbContext;
|
||
_privacy = privacy;
|
||
_documentation = documentation;
|
||
_students = students;
|
||
_shorthandCodes = shorthandCodes;
|
||
_schoolHolidays = schoolHolidays;
|
||
_calendarSettings = calendarSettings;
|
||
_schoolWeather = schoolWeather;
|
||
_periodSchedule = periodSchedule;
|
||
_supervisionDuties = supervisionDuties;
|
||
_letterTemplates = letterTemplates;
|
||
_aiSettings = aiSettings;
|
||
_aiPlanning = aiPlanning;
|
||
_untisSettings = untisSettings;
|
||
_untisIntegration = untisIntegration;
|
||
_untisSync = untisSync;
|
||
_annualPlanSettings = annualPlanSettings;
|
||
_annualPlanSync = annualPlanSync;
|
||
_syncSettings = syncSettings;
|
||
_syncAuth = syncAuth;
|
||
_eventQueue = eventQueue;
|
||
_snapshotService = snapshotService;
|
||
_syncEngine = syncEngine;
|
||
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
||
LoadSubjects();
|
||
LoadShorthandCodes();
|
||
LoadGradingKeyTemplates();
|
||
LoadGradingSchemes();
|
||
LoadBackups();
|
||
IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath);
|
||
AppLockEnabled = _appLock.IsEnabled;
|
||
AppLockTimeoutMinutes = _appLock.TimeoutMinutes;
|
||
RetentionYears = _privacy.RetentionYears;
|
||
LoadExpiredDocuments();
|
||
SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State);
|
||
LoadSchoolHolidays();
|
||
LoadPeriodTimes();
|
||
LoadSupervisionDuties();
|
||
LoadLetterTemplates();
|
||
LoadAiSettings();
|
||
LoadUntisSettings();
|
||
LoadAnnualPlanSettings();
|
||
LoadSyncSettings();
|
||
LoadSyncConflicts();
|
||
}
|
||
|
||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||
|
||
private void LoadLetterTemplates()
|
||
{
|
||
LetterTemplateList.Clear();
|
||
foreach (var template in _letterTemplates.GetTemplates())
|
||
LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template)));
|
||
}
|
||
|
||
public void ImportLetterTemplate(string path)
|
||
{
|
||
LetterTemplateStatus = "";
|
||
try
|
||
{
|
||
var template = _letterTemplates.Import(path);
|
||
var validation = _letterTemplates.Validate(template);
|
||
LoadLetterTemplates();
|
||
LetterTemplateStatus = validation.Issues.Count == 0
|
||
? "Vorlage importiert und ohne Auffälligkeiten geprüft."
|
||
: $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e).";
|
||
}
|
||
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException)
|
||
{
|
||
LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}";
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ValidateLetterTemplate(LetterTemplateListItem? item)
|
||
{
|
||
if (item is null) return;
|
||
var index = LetterTemplateList.IndexOf(item);
|
||
var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model));
|
||
if (index >= 0) LetterTemplateList[index] = refreshed;
|
||
LetterTemplateStatus = refreshed.Validation.Issues.Count == 0
|
||
? $"„{item.Name}“ ist ohne Auffälligkeiten."
|
||
: $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e).";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void DeleteLetterTemplate(LetterTemplateListItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_letterTemplates.Delete(item.Id);
|
||
LetterTemplateList.Remove(item);
|
||
LetterTemplateStatus = "Vorlage gelöscht.";
|
||
}
|
||
|
||
// ── Aufsichten: Laden / Hinzufügen / Löschen ─────────────────────────────
|
||
|
||
private void LoadSupervisionDuties()
|
||
{
|
||
SupervisionDuties.Clear();
|
||
foreach (var d in _supervisionDuties.GetAll())
|
||
SupervisionDuties.Add(new SupervisionDutyItem(d));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddSupervisionDuty()
|
||
{
|
||
NewDutyError = "";
|
||
if (string.IsNullOrWhiteSpace(NewDutyLocation)) { NewDutyError = "Ort/Bezeichnung erforderlich."; return; }
|
||
if (NewDutyAfterPeriod is < 0 or > 10) { NewDutyError = "Muss zwischen 0 und 10 liegen."; return; }
|
||
|
||
try
|
||
{
|
||
_supervisionDuties.Save(new SupervisionDuty
|
||
{
|
||
Weekday = WeekdayDisplay.FromLabel(NewDutyWeekdayName),
|
||
AfterPeriod = NewDutyAfterPeriod,
|
||
Location = NewDutyLocation.Trim(),
|
||
});
|
||
}
|
||
catch (InvalidOperationException ex) { NewDutyError = ex.Message; return; }
|
||
|
||
NewDutyLocation = ""; NewDutyAfterPeriod = 0;
|
||
LoadSupervisionDuties();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void RemoveSupervisionDuty(SupervisionDutyItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_supervisionDuties.Delete(item.Id);
|
||
SupervisionDuties.Remove(item);
|
||
}
|
||
|
||
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
|
||
|
||
private void LoadAiSettings()
|
||
{
|
||
AiEnabled = _aiSettings.Enabled;
|
||
AiUsername = _aiSettings.Username;
|
||
AiIsLoggedIn = _aiSettings.IsLoggedIn;
|
||
if (AiIsLoggedIn) _ = RefreshAiBalance();
|
||
}
|
||
|
||
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
|
||
|
||
private async Task RefreshAiBalance()
|
||
{
|
||
var token = _aiSettings.GetToken();
|
||
if (token is null) return;
|
||
try
|
||
{
|
||
var balance = await _aiPlanning.GetBalanceAsync(token);
|
||
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
|
||
}
|
||
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task AiLogin()
|
||
{
|
||
AiLoginError = "";
|
||
var valid = true;
|
||
if (string.IsNullOrWhiteSpace(AiUsername)) { AiLoginError = "Benutzername erforderlich."; valid = false; }
|
||
if (string.IsNullOrWhiteSpace(AiPassword)) { AiLoginError = "Passwort erforderlich."; valid = false; }
|
||
if (!valid) return;
|
||
|
||
try
|
||
{
|
||
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
|
||
_aiSettings.SetCredentialsAndToken(AiUsername, token);
|
||
AiPassword = "";
|
||
AiIsLoggedIn = true;
|
||
await RefreshAiBalance();
|
||
}
|
||
catch (AiBackendException ex) { AiLoginError = ex.Message; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AiLogout()
|
||
{
|
||
_aiSettings.Logout();
|
||
AiIsLoggedIn = false;
|
||
AiBalanceDisplay = "";
|
||
}
|
||
|
||
// ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ────
|
||
//
|
||
// Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab
|
||
// (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen).
|
||
|
||
private void LoadUntisSettings()
|
||
{
|
||
UntisEnabled = _untisSettings.Enabled;
|
||
UntisIsConfigured = _untisSettings.IsConfigured;
|
||
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
|
||
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
|
||
: "Noch kein Abgleich durchgeführt.";
|
||
UntisApiIsConfigured = _untisSettings.ApiIsConfigured;
|
||
if (_untisSettings.GetApiCredentials() is { } credentials)
|
||
{
|
||
UntisSchool = credentials.School;
|
||
UntisHost = credentials.Host;
|
||
UntisUsername = credentials.Username;
|
||
UntisApiStatus = $"API-Zugang für {credentials.Username} ist lokal verschlüsselt gespeichert.";
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task UntisSaveApi()
|
||
{
|
||
UntisApiStatus = "";
|
||
if (_untisIntegration is null)
|
||
{
|
||
UntisApiStatus = "Die WebUntis-Integration ist nicht verfügbar.";
|
||
return;
|
||
}
|
||
if (string.IsNullOrWhiteSpace(UntisSchool) || string.IsNullOrWhiteSpace(UntisUsername) ||
|
||
string.IsNullOrWhiteSpace(UntisPassword))
|
||
{
|
||
UntisApiStatus = "Schule, Benutzername und Passwort sind erforderlich.";
|
||
return;
|
||
}
|
||
UntisApiBusy = true;
|
||
try
|
||
{
|
||
var credentials = new WebUntisCredentials(UntisSchool.Trim(), UntisHost.Trim(),
|
||
UntisUsername.Trim(), UntisPassword);
|
||
await _untisIntegration.ConnectAsync(credentials);
|
||
_untisSettings.SetApiCredentials(credentials);
|
||
UntisPassword = "";
|
||
UntisApiIsConfigured = true;
|
||
UntisApiStatus = "Anmeldung erfolgreich. Die WebUntis-Session bleibt bei Nutzung bis zu 10 Minuten offen.";
|
||
}
|
||
catch (WebUntisIntegrationException ex) { UntisApiStatus = ex.Message; }
|
||
finally { UntisApiBusy = false; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task UntisRemoveApi()
|
||
{
|
||
UntisApiBusy = true;
|
||
try { if (_untisIntegration is not null) await _untisIntegration.DisconnectAsync(); }
|
||
catch (WebUntisIntegrationException) { /* lokale Zugangsdaten trotzdem sicher entfernen */ }
|
||
finally
|
||
{
|
||
_untisSettings.ClearApiCredentials();
|
||
UntisPassword = "";
|
||
UntisApiIsConfigured = false;
|
||
UntisApiStatus = "WebUntis-API-Zugang entfernt.";
|
||
UntisApiBusy = false;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void UntisSaveUrl()
|
||
{
|
||
UntisUrlError = "";
|
||
if (string.IsNullOrWhiteSpace(UntisIcalUrlInput)) { UntisUrlError = "iCal-URL erforderlich."; return; }
|
||
if (!Uri.TryCreate(UntisIcalUrlInput, UriKind.Absolute, out _)) { UntisUrlError = "Ungültige URL."; return; }
|
||
|
||
_untisSettings.SetIcalUrl(UntisIcalUrlInput.Trim());
|
||
_untisSettings.SetEnabled(true);
|
||
UntisIcalUrlInput = "";
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void UntisRemove()
|
||
{
|
||
_untisSettings.ClearIcalUrl();
|
||
LoadUntisSettings();
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task UntisFetchNow()
|
||
{
|
||
if (_untisSync is null) { UntisStatusDisplay = "Abgleich nicht aktiv — App neu starten."; return; }
|
||
UntisFetchBusy = true;
|
||
try
|
||
{
|
||
await _untisSync.PollAsync();
|
||
LoadUntisSettings();
|
||
}
|
||
finally { UntisFetchBusy = false; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task UntisReviewMapping()
|
||
{
|
||
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
|
||
}
|
||
|
||
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
|
||
|
||
private void LoadAnnualPlanSettings()
|
||
{
|
||
AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured;
|
||
AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at
|
||
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}"
|
||
: "Noch kein Abgleich durchgeführt.";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AnnualPlanSaveUrl()
|
||
{
|
||
AnnualPlanUrlError = "";
|
||
if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput))
|
||
{
|
||
AnnualPlanUrlError = "iCal-URL erforderlich.";
|
||
return;
|
||
}
|
||
if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) ||
|
||
uri.Scheme is not ("http" or "https"))
|
||
{
|
||
AnnualPlanUrlError = "Ungültige HTTP(S)-URL.";
|
||
return;
|
||
}
|
||
|
||
_annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim());
|
||
_annualPlanSettings.SetEnabled(true);
|
||
AnnualPlanIcalUrlInput = "";
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AnnualPlanRemove()
|
||
{
|
||
_annualPlanSync?.Clear();
|
||
_annualPlanSettings.ClearIcalUrl();
|
||
LoadAnnualPlanSettings();
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task AnnualPlanFetchNow()
|
||
{
|
||
if (_annualPlanSync is null)
|
||
{
|
||
AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten.";
|
||
return;
|
||
}
|
||
|
||
AnnualPlanFetchBusy = true;
|
||
try
|
||
{
|
||
await _annualPlanSync.PollAsync();
|
||
LoadAnnualPlanSettings();
|
||
}
|
||
finally
|
||
{
|
||
AnnualPlanFetchBusy = false;
|
||
}
|
||
}
|
||
|
||
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
|
||
//
|
||
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
|
||
// gespeichert (ein Restart deckt beides ab) — SyncEngine/SnapshotService werden nur einmalig
|
||
// beim Start registriert (siehe AppBootstrapper), es gibt keinen Live-Re-Registrierungspfad.
|
||
|
||
private void LoadSyncSettings()
|
||
{
|
||
SyncServerUrl = _syncSettings.ServerUrl;
|
||
SyncUsername = _syncSettings.Username;
|
||
SyncIsLoggedIn = _syncSettings.IsLoggedIn;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task SyncTestConnection()
|
||
{
|
||
SyncConnectionStatus = "Teste Verbindung…";
|
||
if (string.IsNullOrWhiteSpace(SyncServerUrl))
|
||
{
|
||
SyncConnectionStatus = "Bitte Server-Adresse eingeben.";
|
||
return;
|
||
}
|
||
var result = await _syncAuth.TestConnectionAsync(SyncServerUrl, _syncSettings.GetToken());
|
||
SyncConnectionStatus = result switch
|
||
{
|
||
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
|
||
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
|
||
SyncConnectionTestResult.IncompatibleVersion =>
|
||
"Diese App-Version ist veraltet oder nicht mehr mit dem Sync-Server kompatibel. " +
|
||
"Bitte aktualisiere die LehrerApp.",
|
||
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
|
||
};
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task SyncLogin()
|
||
{
|
||
SyncLoginError = "";
|
||
var valid = true;
|
||
if (string.IsNullOrWhiteSpace(SyncServerUrl)) { SyncLoginError = "Server-Adresse erforderlich."; valid = false; }
|
||
if (string.IsNullOrWhiteSpace(SyncUsername)) { SyncLoginError = "Benutzername erforderlich."; valid = false; }
|
||
if (string.IsNullOrWhiteSpace(SyncPassword)) { SyncLoginError = "Passwort erforderlich."; valid = false; }
|
||
if (!valid) return;
|
||
|
||
try
|
||
{
|
||
var (token, userId) = await _syncAuth.LoginAsync(SyncServerUrl, SyncUsername, SyncPassword);
|
||
// Kontowechsel-Erkennung anhand der KANONISCHEN userId aus der Server-Antwort, nicht
|
||
// anhand von SyncUsername: LiteDBs case-insensitive Standard-Collation lässt einen
|
||
// Login mit abweichender Groß-/Kleinschreibung erfolgreich durch (TODO 10.2.5), meldet
|
||
// serverseitig aber trotzdem eine ANDERE userId zurück - die lokale Versionsverfolgung
|
||
// (BasedOnServerSeq je Entität, Pull-Cursor) bezieht sich dann auf ein fremdes Konto
|
||
// und muss verworfen werden, sonst lehnt der Server jeden weiteren Push mit einem
|
||
// dauerhaften 404 beim Nachladen ab (siehe TODO 10.3.5).
|
||
var accountChanged = _syncSettings.LastUserId is not null && _syncSettings.LastUserId != userId;
|
||
_syncSettings.SetServerUrl(SyncServerUrl);
|
||
_syncSettings.SetCredentialsAndToken(SyncUsername, token, userId);
|
||
if (accountChanged)
|
||
{
|
||
_eventQueue.SetLastServerSeq(0);
|
||
_eventQueue.ResetKnownServerSeqs();
|
||
}
|
||
SyncPassword = "";
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
catch (SyncAuthException ex) { SyncLoginError = ex.Message; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void SyncLogout()
|
||
{
|
||
_syncSettings.Logout();
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Setzt den lokalen Sync-Fortschritt (Pull-Cursor UND die per-Entität-Versionsverfolgung für
|
||
/// Push) vollständig zurück und lädt danach alle Ereignisse dieses Nutzers erneut vom Server -
|
||
/// manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts
|
||
/// erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor, TODO 10.1.10) oder eigene
|
||
/// Pushes dauerhaft mit 404 abgelehnt werden (z.B. nach einem Kontowechsel, TODO 10.3.5).
|
||
/// Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/Delete-by-Id idempotent
|
||
/// an, und ein Push ohne bekannten Vorstand wird vom Server als Neuanlage behandelt.
|
||
/// </summary>
|
||
[RelayCommand]
|
||
private async Task SyncForceFullResync()
|
||
{
|
||
if (_syncEngine is null) { SyncForceResyncStatus = "Sync nicht konfiguriert."; return; }
|
||
SyncForceResyncBusy = true;
|
||
SyncForceResyncStatus = "Lade alle Ereignisse erneut…";
|
||
try
|
||
{
|
||
_eventQueue.SetLastServerSeq(0);
|
||
_eventQueue.ResetKnownServerSeqs();
|
||
var result = await _syncEngine.SyncNowAsync();
|
||
SyncForceResyncStatus = result.Success
|
||
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."
|
||
: $"Fehlgeschlagen: {result.Reason}";
|
||
LoadSyncConflicts();
|
||
}
|
||
finally { SyncForceResyncBusy = false; }
|
||
}
|
||
|
||
// ── Sync-Schlüssel: selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────
|
||
//
|
||
// Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe SyncCrypto) - eine echte
|
||
// Wiederherstellung nach Verlust des einzigen Geräts mit dem Schlüssel ist deshalb nur
|
||
// möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde. "Code erzeugen"
|
||
// verschlüsselt den aktuellen Schlüssel mit einem einmalig angezeigten, zufälligen Code
|
||
// (SyncCrypto.EncryptKeyWithRecoveryCode) und lässt den Nutzer die verschlüsselte Datei
|
||
// selbst sichern (USB-Stick, eigene Cloud o.ä.) - Datei und Code getrennt aufbewahren, erst
|
||
// beides zusammen ergibt den Schlüssel. Läuft komplett offline, ohne Server-Beteiligung.
|
||
|
||
[RelayCommand]
|
||
private async Task CreateRecoveryCode()
|
||
{
|
||
if (OnSaveRecoveryFile is null) return;
|
||
RecoveryBusy = true;
|
||
RecoveryStatus = "";
|
||
RecoveryCode = "";
|
||
try
|
||
{
|
||
var code = SyncCrypto.GenerateRecoveryCode();
|
||
var fileContent = _syncKeyRecovery.CreateRecoveryFile(code);
|
||
if (!await OnSaveRecoveryFile(fileContent)) { RecoveryStatus = "Abgebrochen."; return; }
|
||
RecoveryCode = code;
|
||
RecoveryStatus = "Datei gespeichert. Den Code getrennt von der Datei notieren und " +
|
||
"sicher aufbewahren - er wird nirgends gespeichert und lässt sich " +
|
||
"nicht erneut anzeigen.";
|
||
}
|
||
finally { RecoveryBusy = false; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task RedeemRecoveryCode()
|
||
{
|
||
RecoveryStatus = "";
|
||
if (string.IsNullOrWhiteSpace(RecoveryCodeInput)) { RecoveryStatus = "Bitte Code eingeben."; return; }
|
||
if (OnPickRecoveryFile is null) return;
|
||
var fileContent = await OnPickRecoveryFile();
|
||
if (fileContent is null) return;
|
||
if (OnConfirmRecoveryRestore is not null && !await OnConfirmRecoveryRestore()) return;
|
||
|
||
try
|
||
{
|
||
_syncKeyRecovery.RestoreFromFile(fileContent, RecoveryCodeInput.Trim());
|
||
}
|
||
catch (Exception ex) when (ex is CryptographicException or InvalidOperationException or JsonException)
|
||
{
|
||
RecoveryStatus = "Fehlgeschlagen: Code und Datei passen nicht zusammen.";
|
||
_logger.Error("Sync-Schlüssel-Wiederherstellung fehlgeschlagen", ex);
|
||
return;
|
||
}
|
||
// Der neue Schlüssel liegt jetzt auf der Platte, aber der aktuell im Speicher gehaltene
|
||
// (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht werden -
|
||
// gleiches Muster wie RedeemPairingCode. Lokale Cursor/Versionsverfolgung zurücksetzen,
|
||
// falls in der Zwischenzeit (mit dem gerade ersetzten, falschen Schlüssel) bereits etwas
|
||
// synchronisiert wurde - sicher wiederholbar, siehe SyncForceFullResync.
|
||
_eventQueue.SetLastServerSeq(0);
|
||
_eventQueue.ResetKnownServerSeqs();
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
// ── Synchronisation: Konflikte ────────────────────────────────────────────
|
||
//
|
||
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
|
||
// Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur
|
||
// rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion.
|
||
|
||
private void LoadSyncConflicts()
|
||
{
|
||
SyncConflicts.Clear();
|
||
foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt))
|
||
SyncConflicts.Add(new SyncConflictListItem(c));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void MarkConflictReviewed(SyncConflictListItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_eventQueue.MarkReviewed(item.Id);
|
||
SyncConflicts.Remove(item);
|
||
}
|
||
|
||
// ── Geräte-Pairing: Code erzeugen / einlösen ─────────────────────────────
|
||
//
|
||
// CreateAndUploadAsync lädt einen verschlüsselten Snapshot der lokalen Datenbank samt
|
||
// Sync-Schlüssel hoch, RestoreFromCodeAsync ersetzt auf dem ZWEITEN Gerät die dortige
|
||
// Datenbank vollständig durch diesen Snapshot (vorheriger Stand wird automatisch als
|
||
// .backup-Datei gesichert, siehe SnapshotService) — deshalb vor dem Einlösen ein
|
||
// Bestätigungsdialog wie bei RestoreBackup.
|
||
|
||
[RelayCommand]
|
||
private async Task CreatePairingCode()
|
||
{
|
||
if (_snapshotService is null) return;
|
||
PairingBusy = true;
|
||
PairingCode = "";
|
||
PairingStatus = "";
|
||
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
|
||
_snapshotService.ProgressChanged += OnProgress;
|
||
try
|
||
{
|
||
var result = await _snapshotService.CreateAndUploadAsync();
|
||
PairingCode = result.Code;
|
||
PairingStatus = $"Gültig bis {result.ExpiresAt:dd.MM.yyyy HH:mm} — auf dem anderen Gerät eingeben.";
|
||
}
|
||
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
|
||
{
|
||
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
_snapshotService.ProgressChanged -= OnProgress;
|
||
PairingBusy = false;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task RedeemPairingCode()
|
||
{
|
||
if (_snapshotService is null) return;
|
||
if (string.IsNullOrWhiteSpace(PairingCodeInput)) { PairingStatus = "Bitte Code eingeben."; return; }
|
||
if (OnConfirmPairingRestore is not null && !await OnConfirmPairingRestore()) return;
|
||
|
||
PairingBusy = true;
|
||
PairingStatus = "";
|
||
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
|
||
_snapshotService.ProgressChanged += OnProgress;
|
||
|
||
// LiteDB hält beim Öffnen einen exklusiven Dateilock — muss vor dem Überschreiben
|
||
// geschlossen sein. Läuft danach ein Fehler (falscher Code, Server nicht erreichbar), ist
|
||
// _dbContext bereits disposed und die App nicht mehr sicher weiter benutzbar, ohne dass
|
||
// die Datenbankdatei selbst angefasst wurde (RestoreFromCodeAsync schreibt sie erst ganz
|
||
// am Ende) — deshalb in JEDEM Fall (Erfolg wie Fehlschlag) neu starten, nicht nur bei
|
||
// Erfolg. Ein Neustart öffnet dann wieder dieselbe, unveränderte Datenbank.
|
||
_dbContext.Dispose();
|
||
try
|
||
{
|
||
await _snapshotService.RestoreFromCodeAsync(PairingCodeInput.Trim(), AppBootstrapper.DbPath);
|
||
}
|
||
catch (Exception ex) when (ex is SnapshotNotFoundException or InvalidOperationException or HttpRequestException)
|
||
{
|
||
// _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld
|
||
// ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher.
|
||
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
|
||
_logger.Error("Geräte-Pairing (Redeem) fehlgeschlagen", ex);
|
||
// Ohne diesen Dialog würde PairingStatus nie gerendert: der direkt folgende Neustart
|
||
// (unten, auch im Fehlerfall nötig, siehe Kommentar oben) beendet den Prozess, bevor
|
||
// Avalonia den nächsten Frame zeichnen könnte.
|
||
if (OnShowPairingError is not null) await OnShowPairingError(ex.Message);
|
||
}
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
|
||
|
||
private void LoadPeriodTimes()
|
||
{
|
||
PeriodTimes.Clear();
|
||
for (var period = 1; period <= 10; period++)
|
||
{
|
||
var times = _periodSchedule.GetTimes(period);
|
||
PeriodTimes.Add(new PeriodTimeEditItem(period, times?.Start, times?.End));
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void SavePeriodTimes()
|
||
{
|
||
PeriodTimesError = ""; PeriodTimesStatus = "";
|
||
var entries = new List<PeriodTimeEntry>();
|
||
|
||
foreach (var item in PeriodTimes)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(item.StartText) && string.IsNullOrWhiteSpace(item.EndText))
|
||
continue; // Stunde bewusst nicht konfiguriert — ok, keine Pflicht für alle 10.
|
||
|
||
if (!TimeOnly.TryParseExact(item.StartText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) ||
|
||
!TimeOnly.TryParseExact(item.EndText, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
|
||
{ PeriodTimesError = $"{item.PeriodLabel}: Format HH:MM."; return; }
|
||
|
||
if (end <= start)
|
||
{ PeriodTimesError = $"{item.PeriodLabel}: Ende muss nach dem Beginn liegen."; return; }
|
||
|
||
entries.Add(new PeriodTimeEntry { PeriodNumber = item.PeriodNumber, Start = start, End = end });
|
||
}
|
||
|
||
_periodSchedule.SetPeriods(entries);
|
||
PeriodTimesStatus = "Gespeichert.";
|
||
}
|
||
|
||
// ── Ferien & Feiertage: Bundesland / Schulferien pflegen ─────────────────
|
||
|
||
partial void OnSelectedStateNameChanged(string value) =>
|
||
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
|
||
|
||
[RelayCommand]
|
||
private async Task LoadSchoolLocation()
|
||
{
|
||
if (_schoolWeather is null) return;
|
||
try
|
||
{
|
||
var location = await _schoolWeather.GetLocationAsync();
|
||
if (location is null) return;
|
||
SchoolName = location.SchoolName;
|
||
SchoolStreet = location.Street;
|
||
SchoolPostalCode = location.PostalCode;
|
||
SchoolCity = location.City;
|
||
SelectedStateName = GermanStateDisplay.Label(location.State);
|
||
ResolvedSchoolAddress = location.ResolvedAddress;
|
||
}
|
||
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task SaveSchoolLocation()
|
||
{
|
||
SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = "";
|
||
SchoolCityError = ""; SchoolLocationStatus = "";
|
||
var valid = true;
|
||
if (string.IsNullOrWhiteSpace(SchoolName))
|
||
{ SchoolNameError = "Name der Schule erforderlich."; valid = false; }
|
||
if (string.IsNullOrWhiteSpace(SchoolStreet))
|
||
{ SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; }
|
||
if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$"))
|
||
{ SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; }
|
||
if (string.IsNullOrWhiteSpace(SchoolCity))
|
||
{ SchoolCityError = "Ort erforderlich."; valid = false; }
|
||
if (!valid) return;
|
||
if (_schoolWeather is null)
|
||
{
|
||
SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar.";
|
||
return;
|
||
}
|
||
|
||
SchoolLocationStatus = "Adresse wird geprüft …";
|
||
try
|
||
{
|
||
var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest
|
||
{
|
||
SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(),
|
||
PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(),
|
||
State = GermanStateDisplay.FromLabel(SelectedStateName),
|
||
});
|
||
ResolvedSchoolAddress = profile.ResolvedAddress;
|
||
SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen.";
|
||
}
|
||
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
|
||
}
|
||
|
||
private void LoadSchoolHolidays()
|
||
{
|
||
SchoolHolidayEntries.Clear();
|
||
foreach (var h in _schoolHolidays.GetAll().OrderBy(h => h.StartDate))
|
||
SchoolHolidayEntries.Add(new SchoolHolidayItem(h));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddSchoolHoliday()
|
||
{
|
||
HolidayNameError = ""; HolidayDateError = "";
|
||
var valid = true;
|
||
|
||
if (string.IsNullOrWhiteSpace(NewHolidayName)) { HolidayNameError = "Name erforderlich."; valid = false; }
|
||
|
||
var hasStart = DateOnly.TryParseExact(NewHolidayStartText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||
System.Globalization.DateTimeStyles.None, out var start);
|
||
var hasEnd = DateOnly.TryParseExact(NewHolidayEndText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
|
||
System.Globalization.DateTimeStyles.None, out var end);
|
||
|
||
if (!hasStart || !hasEnd) { HolidayDateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; valid = false; }
|
||
else if (end < start) { HolidayDateError = "Das Ende darf nicht vor dem Beginn liegen."; valid = false; }
|
||
|
||
if (!valid) return;
|
||
|
||
_schoolHolidays.Save(new SchoolHoliday { Name = NewHolidayName.Trim(), StartDate = start, EndDate = end });
|
||
NewHolidayName = ""; NewHolidayStartText = ""; NewHolidayEndText = "";
|
||
LoadSchoolHolidays();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void RemoveSchoolHoliday(SchoolHolidayItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_schoolHolidays.Delete(item.Id);
|
||
SchoolHolidayEntries.Remove(item);
|
||
}
|
||
|
||
// ── Datenschutz: Löschfristen ─────────────────────────────────────────────
|
||
|
||
private void LoadExpiredDocuments()
|
||
{
|
||
ExpiredDocuments.Clear();
|
||
var cutoff = _privacy.RetentionCutoff();
|
||
foreach (var d in _documentation.GetAll().Where(d => d.CreatedAt < cutoff))
|
||
{
|
||
var student = _students.GetById(d.StudentId);
|
||
ExpiredDocuments.Add(new ExpiredDocumentItem(d, student?.FullName ?? "?"));
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void SaveRetentionYears()
|
||
{
|
||
_privacy.SetRetentionYears(RetentionYears);
|
||
LoadExpiredDocuments();
|
||
RetentionStatus = "Gespeichert.";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task HardDeleteDocument(ExpiredDocumentItem? item)
|
||
{
|
||
if (item is null) return;
|
||
if (OnConfirmHardDelete is not null && !await OnConfirmHardDelete(item)) return;
|
||
_documentation.HardDelete(item.Id);
|
||
ExpiredDocuments.Remove(item);
|
||
}
|
||
|
||
// ── Datensicherung: Laden / Erstellen / Wiederherstellen ─────────────────
|
||
|
||
private void LoadBackups()
|
||
{
|
||
Backups.Clear();
|
||
foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CreateBackupNow()
|
||
{
|
||
_backups.CreateBackup(AppBootstrapper.DbPath);
|
||
LoadBackups();
|
||
BackupStatus = "Backup erstellt.";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task RestoreBackup(BackupListItem? item)
|
||
{
|
||
if (item is null) return;
|
||
if (OnConfirmRestore is not null && !await OnConfirmRestore(item)) return;
|
||
|
||
_dbContext.Dispose();
|
||
_backups.RestoreBackup(item.Path, AppBootstrapper.DbPath);
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
// ── Datenbank-Verschlüsselung: Setzen / Ändern / Entfernen ───────────────
|
||
|
||
[RelayCommand]
|
||
private void SaveDbPassword()
|
||
{
|
||
DbPasswordError = "";
|
||
var valid = true;
|
||
var wantsPassword = !string.IsNullOrWhiteSpace(NewDbPassword) || !string.IsNullOrWhiteSpace(NewDbPasswordConfirm);
|
||
|
||
if (IsDbEncrypted && !_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
|
||
{
|
||
DbPasswordError = "Aktuelles Passwort ist falsch.";
|
||
valid = false;
|
||
}
|
||
|
||
if (!wantsPassword)
|
||
{
|
||
DbPasswordError = "Bitte ein neues Passwort eingeben.";
|
||
valid = false;
|
||
}
|
||
else if (NewDbPassword != NewDbPasswordConfirm)
|
||
{
|
||
DbPasswordError = "Neue Passwörter stimmen nicht überein.";
|
||
valid = false;
|
||
}
|
||
else if (NewDbPassword.Length < 4)
|
||
{
|
||
DbPasswordError = "Mindestens 4 Zeichen.";
|
||
valid = false;
|
||
}
|
||
|
||
if (!valid) return;
|
||
|
||
_dbContext.Dispose();
|
||
_dbEncryption.SetPassword(AppBootstrapper.DbPath, IsDbEncrypted ? CurrentDbPassword : null, NewDbPassword.Trim());
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void RemoveDbPassword()
|
||
{
|
||
DbPasswordError = "";
|
||
if (!_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword))
|
||
{
|
||
DbPasswordError = "Aktuelles Passwort ist falsch.";
|
||
return;
|
||
}
|
||
|
||
_dbContext.Dispose();
|
||
_dbEncryption.SetPassword(AppBootstrapper.DbPath, CurrentDbPassword, null);
|
||
AppBootstrapper.RestartApplication();
|
||
}
|
||
|
||
// ── App-Sperre: Speichern ─────────────────────────────────────────────────
|
||
|
||
[RelayCommand]
|
||
private void SaveAppLockSettings()
|
||
{
|
||
AppLockPasswordError = "";
|
||
var valid = true;
|
||
var wantsNewPassword = !string.IsNullOrWhiteSpace(AppLockNewPassword) || !string.IsNullOrWhiteSpace(AppLockNewPasswordConfirm);
|
||
|
||
if (AppLockEnabled && !_appLock.HasPassword && !wantsNewPassword)
|
||
{
|
||
AppLockPasswordError = "Bitte ein Passwort für die App-Sperre festlegen.";
|
||
valid = false;
|
||
}
|
||
else if (wantsNewPassword)
|
||
{
|
||
if (AppLockNewPassword != AppLockNewPasswordConfirm)
|
||
{
|
||
AppLockPasswordError = "Passwörter stimmen nicht überein.";
|
||
valid = false;
|
||
}
|
||
else if (AppLockNewPassword.Length < 4)
|
||
{
|
||
AppLockPasswordError = "Mindestens 4 Zeichen.";
|
||
valid = false;
|
||
}
|
||
}
|
||
|
||
if (!valid) return;
|
||
|
||
if (wantsNewPassword) _appLock.SetPassword(AppLockNewPassword.Trim());
|
||
_appLock.Configure(AppLockEnabled, AppLockTimeoutMinutes);
|
||
|
||
AppLockNewPassword = ""; AppLockNewPasswordConfirm = "";
|
||
OnPropertyChanged(nameof(AppLockHasPassword));
|
||
AppLockStatus = "Gespeichert.";
|
||
OnAppLockChanged?.Invoke();
|
||
}
|
||
|
||
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
|
||
|
||
private void LoadGradingSchemes()
|
||
{
|
||
ClassScheme = new GradingSchemeEditItem(
|
||
_gradingSchemes.GetDefaultForType(GroupType.Class)
|
||
?? new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 },
|
||
"Klassen", _gradingSchemes, _grading);
|
||
CourseScheme = new GradingSchemeEditItem(
|
||
_gradingSchemes.GetDefaultForType(GroupType.Course)
|
||
?? new GradingScheme { GroupType = GroupType.Course, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 },
|
||
"Kurse", _gradingSchemes, _grading);
|
||
}
|
||
|
||
// ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ────────────────
|
||
|
||
private void LoadGradingKeyTemplates()
|
||
{
|
||
GradingKeyTemplateList.Clear();
|
||
foreach (var t in _gradingKeyTemplates.GetAll())
|
||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(t, _gradingKeyTemplates, _grading));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddGradingKeyTemplate()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(NewTemplateName)) { NewTemplateNameError = "Vorlagenname erforderlich."; return; }
|
||
|
||
var system = NewTemplateGradingSystemName == "Punkte 0–15"
|
||
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
|
||
var defaults = system == GradingSystem.Grades1To6
|
||
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
|
||
|
||
var template = new GradingKeyTemplate
|
||
{
|
||
Name = NewTemplateName.Trim(),
|
||
GradingSystem = system,
|
||
Entries = defaults.Select(e => new GradingKeyEntry { Grade = e.Grade, MinPercent = e.MinPercent }).ToList(),
|
||
};
|
||
_gradingKeyTemplates.Save(template);
|
||
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
|
||
NewTemplateName = "";
|
||
NewTemplateNameError = "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void DeleteGradingKeyTemplate(GradingKeyTemplateEditItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_gradingKeyTemplates.Delete(item.Id);
|
||
GradingKeyTemplateList.Remove(item);
|
||
}
|
||
|
||
// ── Fächer: Laden / Hinzufügen / Löschen ─────────────────────────────────
|
||
|
||
public void LoadSubjects()
|
||
{
|
||
Subjects.Clear();
|
||
foreach (var s in _subjects.GetAll())
|
||
Subjects.Add(new SubjectListItem(s));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddSubject()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(NewName)) { NewNameError = "Name erforderlich."; return; }
|
||
try
|
||
{
|
||
_subjects.Save(new Subject { Name = NewName.Trim(), ShortName = NewShort.Trim() });
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
NewNameError = ex.Message;
|
||
return;
|
||
}
|
||
NewName = ""; NewShort = ""; NewNameError = "";
|
||
LoadSubjects();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void DeleteSubject(SubjectListItem? item)
|
||
{
|
||
if (item is null) return;
|
||
try
|
||
{
|
||
_subjects.Delete(item.Id);
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
NewNameError = ex.Message;
|
||
return;
|
||
}
|
||
NewNameError = "";
|
||
if (CatalogSubject?.Id == item.Id) CatalogSubject = null;
|
||
LoadSubjects();
|
||
}
|
||
|
||
// ── Kürzel-Katalog: Laden / Hinzufügen / Löschen ─────────────────────────
|
||
|
||
public void LoadShorthandCodes()
|
||
{
|
||
ShorthandCodes.Clear();
|
||
foreach (var c in _shorthandCodes.GetAll())
|
||
ShorthandCodes.Add(new ShorthandCodeListItem(c));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddShorthandCode()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(NewShorthandCode)) { NewShorthandCodeError = "Kürzel erforderlich."; return; }
|
||
try
|
||
{
|
||
_shorthandCodes.Save(new ShorthandCode { Code = NewShorthandCode.Trim(), Label = NewShorthandLabel.Trim() });
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
NewShorthandCodeError = ex.Message;
|
||
return;
|
||
}
|
||
NewShorthandCode = ""; NewShorthandLabel = ""; NewShorthandCodeError = "";
|
||
LoadShorthandCodes();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void DeleteShorthandCode(ShorthandCodeListItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_shorthandCodes.Delete(item.Id);
|
||
LoadShorthandCodes();
|
||
}
|
||
|
||
// ── Katalog: Laden ────────────────────────────────────────────────────────
|
||
|
||
partial void OnCatalogSubjectChanged(SubjectListItem? value) => LoadCatalog();
|
||
partial void OnCatalogGradeLevelChanged(int value) => LoadCatalog();
|
||
|
||
private void LoadCatalog()
|
||
{
|
||
Domains.Clear();
|
||
CatalogValidation = "";
|
||
if (CatalogSubject is null) return;
|
||
foreach (var d in _domainRepo.GetBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel))
|
||
Domains.Add(CreateDomainEditItem(d));
|
||
RefreshDomainMoveState();
|
||
}
|
||
|
||
// ── Katalog: Bereich hinzufügen / löschen ────────────────────────────────
|
||
|
||
[RelayCommand]
|
||
private void AddDomain()
|
||
{
|
||
if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; }
|
||
if (string.IsNullOrWhiteSpace(NewDomainName)) { CatalogValidation = "Bereichsname erforderlich."; return; }
|
||
|
||
var domain = new CompetencyDomain
|
||
{
|
||
SubjectId = CatalogSubject.Id,
|
||
GradeLevel = CatalogGradeLevel,
|
||
Name = NewDomainName.Trim(),
|
||
Code = NewDomainCode.Trim(),
|
||
SortOrder = Domains.Count,
|
||
};
|
||
_domainRepo.Save(domain);
|
||
Domains.Add(CreateDomainEditItem(domain));
|
||
RefreshDomainMoveState();
|
||
NewDomainName = ""; NewDomainCode = ""; CatalogValidation = "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void DeleteDomain(DomainEditItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_domainRepo.Delete(item.Id);
|
||
Domains.Remove(item);
|
||
PersistDomainOrder();
|
||
}
|
||
|
||
private DomainEditItem CreateDomainEditItem(CompetencyDomain domain) =>
|
||
new(domain, _domainRepo, item => MoveDomain(item, -1), item => MoveDomain(item, 1));
|
||
|
||
private void MoveDomain(DomainEditItem item, int offset)
|
||
{
|
||
var oldIndex = Domains.IndexOf(item);
|
||
var newIndex = oldIndex + offset;
|
||
if (oldIndex < 0 || newIndex < 0 || newIndex >= Domains.Count) return;
|
||
Domains.Move(oldIndex, newIndex);
|
||
PersistDomainOrder();
|
||
}
|
||
|
||
private void PersistDomainOrder()
|
||
{
|
||
for (var i = 0; i < Domains.Count; i++) Domains[i].SetSortOrder(i);
|
||
RefreshDomainMoveState();
|
||
}
|
||
|
||
private void RefreshDomainMoveState()
|
||
{
|
||
for (var i = 0; i < Domains.Count; i++)
|
||
Domains[i].SetMoveState(i > 0, i < Domains.Count - 1);
|
||
}
|
||
|
||
// ── JSON Import / Export ──────────────────────────────────────────────────
|
||
|
||
public CompetencyCatalogImportPreview? PrepareCatalogImport(string json)
|
||
{
|
||
if (CatalogSubject is null)
|
||
{
|
||
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
|
||
return null;
|
||
}
|
||
try
|
||
{
|
||
CatalogValidation = "";
|
||
return _catalogImport.Analyze(
|
||
json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel);
|
||
}
|
||
catch (InvalidDataException ex)
|
||
{
|
||
CatalogValidation = ex.Message;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public void ApplyCatalogImport(CompetencyCatalogImportPreview preview,
|
||
CompetencyCatalogImportMode mode, IReadOnlySet<string> useImportedConflicts)
|
||
{
|
||
_catalogImport.Apply(preview, mode, useImportedConflicts);
|
||
LoadCatalog();
|
||
CatalogValidation = mode == CompetencyCatalogImportMode.Merge
|
||
? "Kompetenzkatalog wurde sicher zusammengeführt."
|
||
: "Kompetenzkatalog wurde vollständig ersetzt.";
|
||
}
|
||
|
||
public string ExportCatalog()
|
||
=> SerializeCatalog(CatalogGradeLevel);
|
||
|
||
public CompetencyCatalogImportPreview? PrepareCatalogCopy(int targetGradeLevel)
|
||
{
|
||
if (CatalogSubject is null)
|
||
{
|
||
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
|
||
return null;
|
||
}
|
||
if (Domains.Count == 0)
|
||
{
|
||
CatalogValidation = "Der ausgewählte Katalog enthält keine Bereiche.";
|
||
return null;
|
||
}
|
||
if (targetGradeLevel is < 1 or > 13 || targetGradeLevel == CatalogGradeLevel)
|
||
{
|
||
CatalogValidation = "Bitte eine andere Zielklassenstufe zwischen 1 und 13 auswählen.";
|
||
return null;
|
||
}
|
||
|
||
CatalogValidation = "";
|
||
return _catalogImport.Analyze(SerializeCatalog(targetGradeLevel), CatalogSubject.Id,
|
||
CatalogSubject.Name, targetGradeLevel);
|
||
}
|
||
|
||
public void SetCatalogCopyStatus(int targetGradeLevel) =>
|
||
CatalogValidation = $"Kompetenzkatalog wurde in Klassenstufe {targetGradeLevel} kopiert.";
|
||
|
||
private string SerializeCatalog(int gradeLevel)
|
||
{
|
||
var dto = new CatalogDto
|
||
{
|
||
Subject = CatalogSubject?.Name ?? "",
|
||
GradeLevel = gradeLevel,
|
||
Domains = Domains.Select(d => new DomainDto
|
||
{
|
||
Name = d.Name,
|
||
Code = d.Code,
|
||
Competencies = d.Items.Select(i => new CompetencyDto
|
||
{
|
||
Code = i.Code,
|
||
Description = i.Description,
|
||
}).ToList(),
|
||
}).ToList(),
|
||
};
|
||
return JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
|
||
}
|
||
}
|
||
|
||
// ── DomainEditItem ────────────────────────────────────────────────────────────
|
||
|
||
public partial class DomainEditItem : ObservableObject
|
||
{
|
||
private readonly CompetencyDomain _domain;
|
||
private readonly ICompetencyDomainRepository _repo;
|
||
|
||
public Guid Id { get; }
|
||
public string Name { get; }
|
||
public string Code { get; }
|
||
public string DisplayName { get; }
|
||
|
||
[ObservableProperty] private string _newItemCode = "";
|
||
[ObservableProperty] private string _newItemDesc = "";
|
||
|
||
public ObservableCollection<CompetencyItemVm> Items { get; } = [];
|
||
public IRelayCommand MoveUpCommand { get; }
|
||
public IRelayCommand MoveDownCommand { get; }
|
||
|
||
public DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo,
|
||
Action<DomainEditItem>? onMoveUp = null, Action<DomainEditItem>? onMoveDown = null)
|
||
{
|
||
_domain = domain;
|
||
_repo = repo;
|
||
Id = domain.Id;
|
||
Name = domain.Name;
|
||
Code = domain.Code;
|
||
DisplayName = string.IsNullOrEmpty(domain.Code)
|
||
? domain.Name
|
||
: $"{domain.Name} ({domain.Code})";
|
||
|
||
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
|
||
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
|
||
|
||
_domain.Items = domain.Items.OrderBy(i => i.SortOrder).ToList();
|
||
foreach (var item in _domain.Items)
|
||
Items.Add(CreateItemViewModel(item));
|
||
RefreshItemMoveState();
|
||
}
|
||
|
||
private bool _canMoveUp;
|
||
private bool _canMoveDown;
|
||
|
||
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
|
||
{
|
||
_canMoveUp = canMoveUp;
|
||
_canMoveDown = canMoveDown;
|
||
MoveUpCommand.NotifyCanExecuteChanged();
|
||
MoveDownCommand.NotifyCanExecuteChanged();
|
||
}
|
||
|
||
internal void SetSortOrder(int sortOrder)
|
||
{
|
||
if (_domain.SortOrder == sortOrder) return;
|
||
_domain.SortOrder = sortOrder;
|
||
_repo.Save(_domain);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddItem()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(NewItemDesc)) return;
|
||
var item = new CompetencyItem
|
||
{
|
||
Code = NewItemCode.Trim(),
|
||
Description = NewItemDesc.Trim(),
|
||
SortOrder = _domain.Items.Count,
|
||
};
|
||
_domain.Items.Add(item);
|
||
_repo.Save(_domain);
|
||
Items.Add(CreateItemViewModel(item));
|
||
RefreshItemMoveState();
|
||
NewItemCode = ""; NewItemDesc = "";
|
||
}
|
||
|
||
private void DeleteItem(CompetencyItemVm vm)
|
||
{
|
||
_domain.Items.RemoveAll(i => i.Id == vm.ItemId);
|
||
Items.Remove(vm);
|
||
PersistItemOrder();
|
||
}
|
||
|
||
private CompetencyItemVm CreateItemViewModel(CompetencyItem item) =>
|
||
new(item, DeleteItem, vm => MoveItem(vm, -1), vm => MoveItem(vm, 1));
|
||
|
||
private void MoveItem(CompetencyItemVm item, int offset)
|
||
{
|
||
var oldIndex = Items.IndexOf(item);
|
||
var newIndex = oldIndex + offset;
|
||
if (oldIndex < 0 || newIndex < 0 || newIndex >= Items.Count) return;
|
||
Items.Move(oldIndex, newIndex);
|
||
PersistItemOrder();
|
||
}
|
||
|
||
private void PersistItemOrder()
|
||
{
|
||
_domain.Items = Items.Select(x => x.Model).ToList();
|
||
for (var i = 0; i < _domain.Items.Count; i++) _domain.Items[i].SortOrder = i;
|
||
_repo.Save(_domain);
|
||
RefreshItemMoveState();
|
||
}
|
||
|
||
private void RefreshItemMoveState()
|
||
{
|
||
for (var i = 0; i < Items.Count; i++)
|
||
Items[i].SetMoveState(i > 0, i < Items.Count - 1);
|
||
}
|
||
}
|
||
|
||
// ── CompetencyItemVm ──────────────────────────────────────────────────────────
|
||
|
||
public class CompetencyItemVm
|
||
{
|
||
internal CompetencyItem Model { get; }
|
||
public Guid ItemId { get; }
|
||
public string Code { get; }
|
||
public string Description { get; }
|
||
public string Display { get; }
|
||
public IRelayCommand DeleteCommand { get; }
|
||
public IRelayCommand MoveUpCommand { get; }
|
||
public IRelayCommand MoveDownCommand { get; }
|
||
|
||
public CompetencyItemVm(CompetencyItem item, Action<CompetencyItemVm> onDelete,
|
||
Action<CompetencyItemVm>? onMoveUp = null, Action<CompetencyItemVm>? onMoveDown = null)
|
||
{
|
||
Model = item;
|
||
ItemId = item.Id;
|
||
Code = item.Code;
|
||
Description = item.Description;
|
||
Display = string.IsNullOrEmpty(item.Code)
|
||
? item.Description
|
||
: $"[{item.Code}] {item.Description}";
|
||
DeleteCommand = new RelayCommand(() => onDelete(this));
|
||
MoveUpCommand = new RelayCommand(() => onMoveUp?.Invoke(this), () => _canMoveUp);
|
||
MoveDownCommand = new RelayCommand(() => onMoveDown?.Invoke(this), () => _canMoveDown);
|
||
}
|
||
|
||
private bool _canMoveUp;
|
||
private bool _canMoveDown;
|
||
|
||
internal void SetMoveState(bool canMoveUp, bool canMoveDown)
|
||
{
|
||
_canMoveUp = canMoveUp;
|
||
_canMoveDown = canMoveDown;
|
||
MoveUpCommand.NotifyCanExecuteChanged();
|
||
MoveDownCommand.NotifyCanExecuteChanged();
|
||
}
|
||
}
|
||
|
||
// ── GradingKeyTemplateEditItem (1.3.2) ─────────────────────────────────────────
|
||
|
||
public partial class GradingKeyTemplateEditItem : ObservableObject
|
||
{
|
||
private readonly GradingKeyTemplate _template;
|
||
private readonly IGradingKeyTemplateRepository _repo;
|
||
private readonly GradingService _grading;
|
||
|
||
public Guid Id { get; }
|
||
public string Name { get; }
|
||
public string GradingSystemLabel { get; }
|
||
|
||
[ObservableProperty] private string _newGrade = "";
|
||
[ObservableProperty] private double _newMinPercent;
|
||
[ObservableProperty] private string _newGradeError = "";
|
||
[ObservableProperty] private string _newMinPercentError = "";
|
||
[ObservableProperty] private string _completenessWarning = "";
|
||
|
||
public ObservableCollection<GradingKeyEntryVm> Entries { get; } = [];
|
||
|
||
public GradingKeyTemplateEditItem(GradingKeyTemplate template, IGradingKeyTemplateRepository repo,
|
||
GradingService grading)
|
||
{
|
||
_template = template; _repo = repo; _grading = grading;
|
||
Id = template.Id;
|
||
Name = template.Name;
|
||
GradingSystemLabel = template.GradingSystem == GradingSystem.Grades1To6
|
||
? "Noten 1–6" : "Punkte 0–15";
|
||
|
||
foreach (var e in template.Entries.OrderByDescending(e => e.MinPercent))
|
||
Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
||
RecomputeCompleteness();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void AddEntry()
|
||
{
|
||
NewGradeError = ""; NewMinPercentError = "";
|
||
var valid = true;
|
||
|
||
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 };
|
||
_template.Entries.Add(entry);
|
||
_template.Entries = _template.Entries.OrderByDescending(e => e.MinPercent).ToList();
|
||
_repo.Save(_template);
|
||
|
||
Entries.Clear();
|
||
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
|
||
NewGrade = ""; NewMinPercent = 0;
|
||
RecomputeCompleteness();
|
||
}
|
||
|
||
private void DeleteEntry(GradingKeyEntryVm vm)
|
||
{
|
||
_template.Entries.RemoveAll(e => e.Grade == vm.Grade && Math.Abs(e.MinPercent - vm.MinPercent) < 0.0001);
|
||
_repo.Save(_template);
|
||
Entries.Remove(vm);
|
||
RecomputeCompleteness();
|
||
}
|
||
|
||
private void RecomputeCompleteness() =>
|
||
CompletenessWarning = _grading.ValidateGradingKey(_template.Entries) ?? "";
|
||
}
|
||
|
||
// ── GradingKeyEntryVm ─────────────────────────────────────────────────────────
|
||
|
||
public class GradingKeyEntryVm
|
||
{
|
||
public string Grade { get; }
|
||
public double MinPercent { get; }
|
||
public string Display { get; }
|
||
public IRelayCommand DeleteCommand { get; }
|
||
|
||
public GradingKeyEntryVm(GradingKeyEntry e, Action<GradingKeyEntryVm> onDelete)
|
||
{
|
||
Grade = e.Grade;
|
||
MinPercent = e.MinPercent;
|
||
Display = $"{Grade} — ab {MinPercent.ToString("0.##")} %";
|
||
DeleteCommand = new RelayCommand(() => onDelete(this));
|
||
}
|
||
}
|
||
|
||
// ── GradingSchemeEditItem (2.3) ────────────────────────────────────────────────
|
||
|
||
public partial class GradingSchemeEditItem : ObservableObject
|
||
{
|
||
private readonly GradingScheme _scheme;
|
||
private readonly IGradingSchemeRepository _repo;
|
||
private readonly GradingService _grading;
|
||
|
||
public string Label { get; }
|
||
|
||
[ObservableProperty] private double _examsPercent;
|
||
[ObservableProperty] private double _participationPercent;
|
||
[ObservableProperty] private double _otherPercent;
|
||
[ObservableProperty] private string _validationMessage = "";
|
||
[ObservableProperty] private string _statusMessage = "";
|
||
|
||
public GradingSchemeEditItem(GradingScheme scheme, string label, IGradingSchemeRepository repo, GradingService grading)
|
||
{
|
||
_scheme = scheme; _repo = repo; _grading = grading;
|
||
Label = label;
|
||
_examsPercent = scheme.ExamsPercent;
|
||
_participationPercent = scheme.ParticipationPercent;
|
||
_otherPercent = scheme.OtherPercent;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Save()
|
||
{
|
||
_scheme.ExamsPercent = ExamsPercent;
|
||
_scheme.ParticipationPercent = ParticipationPercent;
|
||
_scheme.OtherPercent = OtherPercent;
|
||
|
||
var error = _grading.ValidateGradingScheme(_scheme);
|
||
if (error is not null) { ValidationMessage = error; StatusMessage = ""; return; }
|
||
|
||
ValidationMessage = "";
|
||
_repo.Save(_scheme);
|
||
StatusMessage = "Gespeichert.";
|
||
}
|
||
}
|
||
|
||
// ── Hilfklassen ───────────────────────────────────────────────────────────────
|
||
|
||
public class SubjectListItem(Subject s)
|
||
{
|
||
public Guid Id { get; } = s.Id;
|
||
public string Name { get; } = s.Name;
|
||
public string ShortName { get; } = s.ShortName;
|
||
}
|
||
|
||
public class ShorthandCodeListItem(ShorthandCode c)
|
||
{
|
||
public Guid Id { get; } = c.Id;
|
||
public string Code { get; } = c.Code;
|
||
public string Label { get; } = c.Label;
|
||
}
|
||
|
||
public class BackupListItem(BackupInfo info)
|
||
{
|
||
public string Path { get; } = info.Path;
|
||
public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
|
||
}
|
||
|
||
public sealed class LetterTemplateListItem
|
||
{
|
||
public LetterTemplateInfo Model { get; }
|
||
public TemplateValidationResult Validation { get; }
|
||
public Guid Id => Model.Id;
|
||
public string Name => Model.Name;
|
||
public string OriginalFileName => Model.OriginalFileName;
|
||
public bool HasIssues => Validation.Issues.Count > 0;
|
||
public bool HasNoIssues => !HasIssues;
|
||
public string ValidationSummary => HasNoIssues
|
||
? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten"
|
||
: $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)";
|
||
public ObservableCollection<LetterTemplateIssueItem> Issues { get; }
|
||
|
||
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
|
||
{
|
||
Model = model;
|
||
Validation = validation;
|
||
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
|
||
}
|
||
}
|
||
|
||
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
|
||
{
|
||
public string Icon => issue.Severity switch
|
||
{
|
||
TemplateIssueSeverity.Error => "⛔",
|
||
TemplateIssueSeverity.StrongWarning => "⚠",
|
||
_ => "ⓘ",
|
||
};
|
||
public string Message => issue.Message;
|
||
public string Color => issue.Severity switch
|
||
{
|
||
TemplateIssueSeverity.Error => "#DC2626",
|
||
TemplateIssueSeverity.StrongWarning => "#D97706",
|
||
_ => "#6B7280",
|
||
};
|
||
}
|
||
|
||
public class ExpiredDocumentItem(Documentation d, string studentName)
|
||
{
|
||
public Guid Id { get; } = d.Id;
|
||
public string StudentName { get; } = studentName;
|
||
public string Title { get; } = d.Title;
|
||
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
|
||
}
|
||
|
||
public class SyncConflictListItem(ConflictEntry c)
|
||
{
|
||
public Guid Id { get; } = c.Id;
|
||
public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)";
|
||
public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm");
|
||
public string ResolutionDisplay { get; } = c.Resolution switch
|
||
{
|
||
"LocalWon" => "Lokale Änderung übernommen (dieses Gerät)",
|
||
"RemoteWon" => "Änderung vom anderen Gerät übernommen",
|
||
_ => c.Resolution,
|
||
};
|
||
}
|
||
|
||
public class SchoolHolidayItem(SchoolHoliday h)
|
||
{
|
||
public Guid Id { get; } = h.Id;
|
||
public string Name { get; } = h.Name;
|
||
public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} – {h.EndDate:dd.MM.yyyy}";
|
||
}
|
||
|
||
public partial class PeriodTimeEditItem : ObservableObject
|
||
{
|
||
public int PeriodNumber { get; }
|
||
public string PeriodLabel { get; }
|
||
|
||
[ObservableProperty] private string _startText;
|
||
[ObservableProperty] private string _endText;
|
||
|
||
public PeriodTimeEditItem(int periodNumber, TimeOnly? start, TimeOnly? end)
|
||
{
|
||
PeriodNumber = periodNumber;
|
||
PeriodLabel = $"{periodNumber}. Stunde";
|
||
_startText = start?.ToString("HH:mm") ?? "";
|
||
_endText = end?.ToString("HH:mm") ?? "";
|
||
}
|
||
}
|
||
|
||
// ── Wochentag: deutsche Anzeige (Mo–Fr, für Aufsichten) ────────────────────────
|
||
|
||
public static class WeekdayDisplay
|
||
{
|
||
private static readonly (DayOfWeek Day, string Name)[] Entries =
|
||
[
|
||
(DayOfWeek.Monday, "Montag"), (DayOfWeek.Tuesday, "Dienstag"), (DayOfWeek.Wednesday, "Mittwoch"),
|
||
(DayOfWeek.Thursday, "Donnerstag"), (DayOfWeek.Friday, "Freitag"),
|
||
];
|
||
|
||
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
|
||
public static string Label(DayOfWeek d) => Entries.First(e => e.Day == d).Name;
|
||
public static DayOfWeek FromLabel(string label) => Entries.FirstOrDefault(e => e.Name == label).Day;
|
||
}
|
||
|
||
public class SupervisionDutyItem(SupervisionDuty d)
|
||
{
|
||
public Guid Id { get; } = d.Id;
|
||
public string WeekdayLabel { get; } = WeekdayDisplay.Label(d.Weekday);
|
||
public int AfterPeriod { get; } = d.AfterPeriod;
|
||
public string PeriodLabel { get; } = d.AfterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {d.AfterPeriod}. Stunde";
|
||
public string Location { get; } = d.Location;
|
||
}
|
||
|
||
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
|
||
|
||
internal class CatalogDto
|
||
{
|
||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||
[JsonPropertyName("gradeLevel")] public int GradeLevel { get; set; }
|
||
[JsonPropertyName("domains")] public List<DomainDto>? Domains { get; set; }
|
||
}
|
||
|
||
internal class DomainDto
|
||
{
|
||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||
[JsonPropertyName("competencies")] public List<CompetencyDto>? Competencies { get; set; }
|
||
}
|
||
|
||
internal class CompetencyDto
|
||
{
|
||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||
}
|