Dokumentationseinträge mit Typwahl (Gespräch, Vorkommnis, Förderplan, Fehlzeit, Elternanruf, Elternbrief), vertrauliche Einträge nur nach Bestätigung sichtbar, weiche Löschung mit Nachvollziehbarkeit. Fehlzeiten als Auswertung des bestehenden Anwesenheits-Trackings statt zweiter Erfassung, mit Schwellenwert-Warnung im Schülerdetail und Dashboard. Förderplan-Wiedervorlage als Dashboard-Karte. Datenschutz: Löschfristen mit manueller Bereinigung und DSGVO-Art.-15-Datenauskunft als Export. Auf Nutzer-Feedback hin ergänzt: Elternanruf mit begleitendem Gesprächsprotokoll-Dialog (Punkte abhaken, Eindrücke festhalten), Elternbrief mit Versand-/Rückmeldungs-Tracking, Datei-Anhänge über LiteDBs Dateispeicher, frei vergebbare Labels zur Nachverfolgung mit Dringlichkeits-Farbcodierung, sowie eine sichtbare Farblegende für das bestehende Notenentwicklungs-Diagramm. Dabei einen Absturz behoben: leere Textfelder lieferten über das Binding null statt "", was beim Speichern eine NullReferenceException auslöste. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
741 lines
29 KiB
C#
741 lines
29 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 System.Collections.ObjectModel;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||
|
||
// ── 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;
|
||
|
||
// ── Fächer ────────────────────────────────────────────────────────────────
|
||
|
||
[ObservableProperty] private string _newName = "";
|
||
[ObservableProperty] private string _newShort = "";
|
||
[ObservableProperty] private string _newNameError = "";
|
||
|
||
public ObservableCollection<SubjectListItem> Subjects { 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; } = [];
|
||
|
||
// ── 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; }
|
||
|
||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||
|
||
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)
|
||
{
|
||
_subjects = subjects;
|
||
_domainRepo = domainRepo;
|
||
_gradingKeyTemplates = gradingKeyTemplates;
|
||
_gradingSchemes = gradingSchemes;
|
||
_grading = grading;
|
||
_backups = backups;
|
||
_dbEncryption = dbEncryption;
|
||
_appLock = appLock;
|
||
_dbContext = dbContext;
|
||
_privacy = privacy;
|
||
_documentation = documentation;
|
||
_students = students;
|
||
LoadSubjects();
|
||
LoadGradingKeyTemplates();
|
||
LoadGradingSchemes();
|
||
LoadBackups();
|
||
IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath);
|
||
AppLockEnabled = _appLock.IsEnabled;
|
||
AppLockTimeoutMinutes = _appLock.TimeoutMinutes;
|
||
RetentionYears = _privacy.RetentionYears;
|
||
LoadExpiredDocuments();
|
||
}
|
||
|
||
// ── 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();
|
||
}
|
||
|
||
// ── 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(new DomainEditItem(d, _domainRepo));
|
||
}
|
||
|
||
// ── 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(new DomainEditItem(domain, _domainRepo));
|
||
NewDomainName = ""; NewDomainCode = ""; CatalogValidation = "";
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void DeleteDomain(DomainEditItem? item)
|
||
{
|
||
if (item is null) return;
|
||
_domainRepo.Delete(item.Id);
|
||
Domains.Remove(item);
|
||
}
|
||
|
||
// ── JSON Import / Export ──────────────────────────────────────────────────
|
||
|
||
public void ImportCatalog(string json)
|
||
{
|
||
if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; }
|
||
try
|
||
{
|
||
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||
var dto = JsonSerializer.Deserialize<CatalogDto>(json, opts);
|
||
if (dto?.Domains is null) { CatalogValidation = "Ungültiges JSON-Format."; return; }
|
||
|
||
_domainRepo.DeleteBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel);
|
||
|
||
for (int i = 0; i < dto.Domains.Count; i++)
|
||
{
|
||
var d = dto.Domains[i];
|
||
var domain = new CompetencyDomain
|
||
{
|
||
SubjectId = CatalogSubject.Id,
|
||
GradeLevel = CatalogGradeLevel,
|
||
Name = d.Name ?? "",
|
||
Code = d.Code ?? "",
|
||
SortOrder = i,
|
||
Items = (d.Competencies ?? [])
|
||
.Select((c, j) => new CompetencyItem
|
||
{
|
||
Code = c.Code ?? "",
|
||
Description = c.Description ?? "",
|
||
SortOrder = j,
|
||
}).ToList(),
|
||
};
|
||
_domainRepo.Save(domain);
|
||
}
|
||
CatalogValidation = "";
|
||
LoadCatalog();
|
||
}
|
||
catch
|
||
{
|
||
CatalogValidation = "Import fehlgeschlagen – bitte JSON-Format prüfen.";
|
||
}
|
||
}
|
||
|
||
public string ExportCatalog()
|
||
{
|
||
var dto = new CatalogDto
|
||
{
|
||
Subject = CatalogSubject?.Name ?? "",
|
||
GradeLevel = CatalogGradeLevel,
|
||
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 DomainEditItem(CompetencyDomain domain, ICompetencyDomainRepository repo)
|
||
{
|
||
_domain = domain;
|
||
_repo = repo;
|
||
Id = domain.Id;
|
||
Name = domain.Name;
|
||
Code = domain.Code;
|
||
DisplayName = string.IsNullOrEmpty(domain.Code)
|
||
? domain.Name
|
||
: $"{domain.Name} ({domain.Code})";
|
||
|
||
foreach (var item in domain.Items.OrderBy(i => i.SortOrder))
|
||
Items.Add(new CompetencyItemVm(item, DeleteItem));
|
||
}
|
||
|
||
[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(new CompetencyItemVm(item, DeleteItem));
|
||
NewItemCode = ""; NewItemDesc = "";
|
||
}
|
||
|
||
private void DeleteItem(CompetencyItemVm vm)
|
||
{
|
||
_domain.Items.RemoveAll(i => i.Id == vm.ItemId);
|
||
_repo.Save(_domain);
|
||
Items.Remove(vm);
|
||
}
|
||
}
|
||
|
||
// ── CompetencyItemVm ──────────────────────────────────────────────────────────
|
||
|
||
public class CompetencyItemVm
|
||
{
|
||
public Guid ItemId { get; }
|
||
public string Code { get; }
|
||
public string Description { get; }
|
||
public string Display { get; }
|
||
public IRelayCommand DeleteCommand { get; }
|
||
|
||
public CompetencyItemVm(CompetencyItem item, Action<CompetencyItemVm> onDelete)
|
||
{
|
||
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));
|
||
}
|
||
}
|
||
|
||
// ── 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 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 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");
|
||
}
|
||
|
||
// ── 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; }
|
||
}
|