diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index b9f2a9a..7cf5d9a 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -7,6 +7,7 @@ public interface IStudentRepository Student? GetById(Guid id); List GetAll(bool includeInactive = false); List GetByGroup(Guid groupId); + StudentReferenceSummary GetReferenceSummary(Guid studentId); void Save(Student student); void Delete(Guid id); } diff --git a/LehrerApp.Core/Models/Student.cs b/LehrerApp.Core/Models/Student.cs index 6bd1481..5831a5b 100644 --- a/LehrerApp.Core/Models/Student.cs +++ b/LehrerApp.Core/Models/Student.cs @@ -19,6 +19,8 @@ public class Contact public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = ""; public string Relation { get; set; } = ""; + /// Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“. + public string? LetterSalutation { get; set; } public string? Phone { get; set; } public string? Email { get; set; } public string? Street { get; set; } @@ -40,3 +42,16 @@ public enum ContactInvalidReason } public enum Gender { M, W, D } + +public sealed record StudentReferenceSummary( + int Memberships, + int ExamResults, + int Grades, + int ReportGrades, + int ParticipationEntries, + int DocumentationEntries) +{ + public int Total => Memberships + ExamResults + Grades + ReportGrades + + ParticipationEntries + DocumentationEntries; + public bool HasReferences => Total > 0; +} diff --git a/LehrerApp.Core/Services/GroupRolloverService.cs b/LehrerApp.Core/Services/GroupRolloverService.cs new file mode 100644 index 0000000..fc035ff --- /dev/null +++ b/LehrerApp.Core/Services/GroupRolloverService.cs @@ -0,0 +1,108 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +public sealed record GroupRolloverRequest( + string Name, + string TargetSchoolYear, + int GradeLevel, + IReadOnlyCollection MembershipIds, + bool CopyGradingScheme, + bool ArchiveSource); + +/// Erzeugt eine fachlich saubere Folgegruppe ohne Leistungs- oder Planungsdaten. +public sealed class GroupRolloverService( + IGroupRepository groups, + IGroupMembershipRepository memberships, + IGradingSchemeRepository gradingSchemes, + SchoolYearService schoolYears) +{ + public LearningGroup RollOver(LearningGroup source, GroupRolloverRequest request) + { + var targetSchoolYear = request.TargetSchoolYear.Trim(); + var targetStart = schoolYears.SchoolYearStart(targetSchoolYear); + if (targetSchoolYear != schoolYears.FormatSchoolYear(targetStart.Year)) + throw new InvalidOperationException("Das Zielschuljahr muss das Format JJJJ/JJ haben, z.B. 2027/28."); + var sourceStart = schoolYears.SchoolYearStart(source.SchoolYear); + if (targetStart <= sourceStart) + throw new InvalidOperationException("Das Zielschuljahr muss nach dem bisherigen Schuljahr liegen."); + if (request.GradeLevel is < 1 or > 13) + throw new InvalidOperationException("Die Klassenstufe muss zwischen 1 und 13 liegen."); + if (string.IsNullOrWhiteSpace(request.Name)) + throw new InvalidOperationException("Bitte einen Gruppennamen angeben."); + + var duplicate = groups.GetBySchoolYear(targetSchoolYear, includeInactive: true) + .Any(g => g.Name.Equals(request.Name.Trim(), StringComparison.OrdinalIgnoreCase) + && g.SubjectId == source.SubjectId && g.Type == source.Type); + if (duplicate) + throw new InvalidOperationException( + "Im Zielschuljahr existiert bereits eine gleichnamige Gruppe mit demselben Fach und Typ."); + + var sourceMemberships = memberships.GetByGroup(source.Id) + .ToDictionary(m => m.Id); + var selected = request.MembershipIds.Distinct() + .Select(id => sourceMemberships.GetValueOrDefault(id) + ?? throw new InvalidOperationException("Eine ausgewählte Mitgliedschaft gehört nicht mehr zur Ausgangsgruppe.")) + .ToList(); + if (selected.Count == 0) + throw new InvalidOperationException("Bitte mindestens einen Schüler auswählen."); + + var target = new LearningGroup + { + Name = request.Name.Trim(), + Type = source.Type, + SubjectId = source.SubjectId, + SchoolYear = targetSchoolYear, + GradeLevel = request.GradeLevel, + GradingSystem = source.GradingSystem, + HoursPerWeek = source.HoursPerWeek, + IsActive = true, + IsOwnClass = source.IsOwnClass, + IsDifferentiated = source.IsDifferentiated, + }; + + var sourceWasActive = source.IsActive; + try + { + groups.Save(target); + foreach (var membership in selected) + { + memberships.Save(new GroupMembership + { + StudentId = membership.StudentId, + GroupId = target.Id, + AddedOn = DateOnly.FromDateTime(DateTime.Today), + Period = MembershipPeriod.FullYear, + JoinedAt = targetStart, + LeftAt = null, + Niveau = membership.Niveau, + }); + } + + if (request.CopyGradingScheme && gradingSchemes.GetByGroup(source.Id) is { } scheme) + { + gradingSchemes.Save(new GradingScheme + { + GroupId = target.Id, + ExamsPercent = scheme.ExamsPercent, + ParticipationPercent = scheme.ParticipationPercent, + OtherPercent = scheme.OtherPercent, + }); + } + + if (request.ArchiveSource && source.IsActive) + { + source.IsActive = false; + groups.Save(source); + } + return target; + } + catch + { + source.IsActive = sourceWasActive; + groups.Delete(target.Id); + throw; + } + } +} diff --git a/LehrerApp.Core/Services/LetterTemplateService.cs b/LehrerApp.Core/Services/LetterTemplateService.cs new file mode 100644 index 0000000..32c7fa3 --- /dev/null +++ b/LehrerApp.Core/Services/LetterTemplateService.cs @@ -0,0 +1,316 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Xml.Linq; + +namespace LehrerApp.Core.Services; + +public enum TemplateIssueSeverity +{ + Warning, + StrongWarning, + Error, +} + +public sealed record LetterPlaceholder(string Tag, string Description); + +public sealed record TemplateValidationIssue( + TemplateIssueSeverity Severity, + string Message, + string? Tag = null, + string? SuggestedTag = null); + +public sealed record TemplateValidationResult( + IReadOnlyList Tags, + IReadOnlyList Issues) +{ + public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error); + public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning); +} + +public sealed class LetterTemplateInfo +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = ""; + public string StoredFileName { get; set; } = ""; + public string OriginalFileName { get; set; } = ""; + public DateTime ImportedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags. +/// Die Originalvorlage wird nie verändert. +/// +public sealed class LetterTemplateService +{ + private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public static IReadOnlyList SupportedPlaceholders { get; } = + [ + new("Student.FirstName", "Vorname des Schülers"), + new("Student.LastName", "Nachname des Schülers"), + new("Contact.Name", "Name des ausgewählten Kontakts"), + new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"), + new("Contact.Street", "Straße und Hausnummer"), + new("Contact.PostalCode", "Postleitzahl"), + new("Contact.City", "Ort"), + new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"), + new("Group.Name", "Ausgewählte Lerngruppe"), + new("SchoolYear", "Schuljahr der ausgewählten Gruppe"), + new("CurrentDate", "Gewähltes Briefdatum"), + ]; + + private readonly string _templateDirectory; + private readonly string _indexPath; + + public LetterTemplateService(string appDataPath) + { + _templateDirectory = Path.Combine(appDataPath, "letter-templates"); + _indexPath = Path.Combine(_templateDirectory, "templates.json"); + Directory.CreateDirectory(_templateDirectory); + } + + public IReadOnlyList GetTemplates() => LoadIndex() + .OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + public string GetTemplatePath(LetterTemplateInfo template) => + Path.Combine(_templateDirectory, template.StoredFileName); + + public LetterTemplateInfo Import(string sourcePath, string? displayName = null) + { + var validation = Validate(sourcePath); + if (!validation.CanGenerate) + throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message); + + var templates = LoadIndex(); + var info = new LetterTemplateInfo + { + Name = string.IsNullOrWhiteSpace(displayName) + ? Path.GetFileNameWithoutExtension(sourcePath) + : displayName.Trim(), + OriginalFileName = Path.GetFileName(sourcePath), + }; + info.StoredFileName = $"{info.Id:N}.docx"; + File.Copy(sourcePath, GetTemplatePath(info), overwrite: false); + templates.Add(info); + SaveIndex(templates); + return info; + } + + public void Delete(Guid id) + { + var templates = LoadIndex(); + var template = templates.FirstOrDefault(t => t.Id == id); + if (template is null) return; + var path = GetTemplatePath(template); + if (File.Exists(path)) File.Delete(path); + templates.Remove(template); + SaveIndex(templates); + } + + public TemplateValidationResult Validate(LetterTemplateInfo template) => + Validate(GetTemplatePath(template)); + + public TemplateValidationResult Validate(string path) + { + var tags = new List(); + var issues = new List(); + + if (!File.Exists(path)) + return Error("Die Vorlagendatei wurde nicht gefunden."); + if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase)) + return Error("Die Vorlage muss eine DOCX-Datei sein."); + + try + { + using var archive = ZipFile.OpenRead(path); + var xmlEntries = WordXmlEntries(archive).ToList(); + if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase))) + return Error("Die Datei ist kein gültiges Word-DOCX-Dokument."); + + foreach (var entry in xmlEntries) + { + using var stream = entry.Open(); + var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace); + foreach (var control in document.Descendants(W + "sdt")) + { + var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(tag)) + { + issues.Add(new(TemplateIssueSeverity.StrongWarning, + $"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden.")); + continue; + } + tags.Add(tag); + } + } + } + catch (InvalidDataException) + { + return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument."); + } + catch (System.Xml.XmlException) + { + return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden."); + } + catch (IOException ex) + { + return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}"); + } + + if (tags.Count == 0 && issues.Count == 0) + issues.Add(new(TemplateIssueSeverity.Warning, + "Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt.")); + + foreach (var tag in tags.Distinct(StringComparer.Ordinal)) + { + if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue; + var suggestion = FindSuggestion(tag); + issues.Add(suggestion is null + ? new(TemplateIssueSeverity.Warning, + $"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag) + : new(TemplateIssueSeverity.StrongWarning, + $"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion)); + } + + return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues); + + TemplateValidationResult Error(string message) => + new([], [new(TemplateIssueSeverity.Error, message)]); + } + + public void Generate(string templatePath, string outputPath, + IReadOnlyDictionary values) + { + var validation = Validate(templatePath); + if (!validation.CanGenerate) + throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message); + + var outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory); + + using var source = ZipFile.OpenRead(templatePath); + using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create); + foreach (var entry in source.Entries) + { + var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal); + targetEntry.LastWriteTime = entry.LastWriteTime; + using var input = entry.Open(); + using var output = targetEntry.Open(); + + if (!IsWordXmlEntry(entry)) + { + input.CopyTo(output); + continue; + } + + var document = XDocument.Load(input, LoadOptions.PreserveWhitespace); + foreach (var control in document.Descendants(W + "sdt").ToList()) + { + var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim(); + if (tag is null || !values.TryGetValue(tag, out var value)) continue; + ReplaceContent(control.Element(W + "sdtContent"), value ?? ""); + } + document.Save(output, SaveOptions.DisableFormatting); + } + } + + private static void ReplaceContent(XElement? content, string value) + { + if (content is null) return; + var paragraph = content.Elements(W + "p").FirstOrDefault(); + XElement run; + + if (paragraph is not null) + { + run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r"); + var paragraphProperties = paragraph.Element(W + "pPr"); + var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null; + paragraph.RemoveNodes(); + if (paragraphProperties is not null) paragraph.Add(paragraphProperties); + run = new XElement(W + "r"); + if (runProperties is not null) run.Add(runProperties); + paragraph.Add(run); + foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove(); + foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove(); + } + else + { + var firstRun = content.Descendants(W + "r").FirstOrDefault(); + var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null; + content.RemoveNodes(); + run = new XElement(W + "r"); + if (runProperties is not null) run.Add(runProperties); + content.Add(run); + } + + var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + if (i > 0) run.Add(new XElement(W + "br")); + run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i])); + } + } + + private static IEnumerable WordXmlEntries(ZipArchive archive) => + archive.Entries.Where(IsWordXmlEntry); + + private static bool IsWordXmlEntry(ZipArchiveEntry entry) => + entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase) + && entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase); + + private static string PartLabel(string path) => path switch + { + "word/document.xml" => "Dokumenttext", + var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile", + var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile", + _ => $"dem Dokumentteil „{Path.GetFileName(path)}“", + }; + + private static string? FindSuggestion(string unknown) + { + var candidate = SupportedPlaceholders + .Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant()))) + .OrderBy(x => x.Distance) + .ThenBy(x => x.Tag) + .First(); + var threshold = candidate.Tag.Length >= 12 ? 3 : 2; + return candidate.Distance <= threshold ? candidate.Tag : null; + } + + private static int Levenshtein(string left, string right) + { + var previous = Enumerable.Range(0, right.Length + 1).ToArray(); + for (var i = 1; i <= left.Length; i++) + { + var current = new int[right.Length + 1]; + current[0] = i; + for (var j = 1; j <= right.Length; j++) + current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), + previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1)); + previous = current; + } + return previous[right.Length]; + } + + private List LoadIndex() + { + if (!File.Exists(_indexPath)) return []; + try + { + return JsonSerializer.Deserialize>(File.ReadAllText(_indexPath), JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private void SaveIndex(List templates) + { + var temporaryPath = _indexPath + ".tmp"; + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions)); + File.Move(temporaryPath, _indexPath, overwrite: true); + } +} diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 93cb84e..7770c4d 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -40,6 +40,37 @@ public sealed class RepositoryTests Assert.Equal(2, repo.GetAll(includeInactive: true).Count); } + [Fact] + public void StudentRepository_Delete_LehntVerknuepfteDatenAb() + { + using var db = NewInMemoryContext(); + var repo = new StudentRepository(db); + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + repo.Save(student); + db.Memberships.Insert(new GroupMembership { StudentId = student.Id, GroupId = Guid.NewGuid() }); + db.Grades.Insert(new Grade { StudentId = student.Id, GroupId = Guid.NewGuid(), Value = "2" }); + + var references = repo.GetReferenceSummary(student.Id); + + Assert.Equal(1, references.Memberships); + Assert.Equal(1, references.Grades); + Assert.Throws(() => repo.Delete(student.Id)); + Assert.NotNull(repo.GetById(student.Id)); + } + + [Fact] + public void StudentRepository_Delete_EntferntUnverknuepftenSchueler() + { + using var db = NewInMemoryContext(); + var repo = new StudentRepository(db); + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + repo.Save(student); + + repo.Delete(student.Id); + + Assert.Null(repo.GetById(student.Id)); + } + // ── GroupRepository ─────────────────────────────────────────────────────── [Fact] @@ -66,6 +97,53 @@ public sealed class RepositoryTests Assert.NotNull(groupRepo.GetAll(includeInactive: true).FirstOrDefault(g => g.Name == "Mathe G")); } + [Fact] + public void ArchivierteGruppe_BlockiertZentraleSchreibvorgaenge() + { + using var db = NewInMemoryContext(); + var groupRepo = new GroupRepository(db); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26", IsActive = true }; + groupRepo.Save(group); + group.IsActive = false; + groupRepo.Save(group); + + var membership = new GroupMembership { GroupId = group.Id, StudentId = Guid.NewGuid() }; + var exam = new Exam { GroupId = group.Id, Title = "Test" }; + + Assert.Throws(() => new GroupMembershipRepository(db).Save(membership)); + Assert.Throws(() => new ExamRepository(db).Save(exam)); + Assert.Throws(() => new GradeRepository(db).Save( + new Grade { GroupId = group.Id, StudentId = Guid.NewGuid(), Value = "2" })); + Assert.Throws(() => new UnitRepository(db).Save( + new Unit { GroupId = group.Id, Title = "Einheit" })); + Assert.Throws(() => new TimetableSlotRepository(db).Save( + new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 })); + } + + [Fact] + public void ArchivierteGruppe_MussVorKorrekturSeparatReaktiviertWerden() + { + using var db = NewInMemoryContext(); + var repo = new GroupRepository(db); + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26", IsActive = true }; + repo.Save(group); + group.IsActive = false; + repo.Save(group); + + var archived = repo.GetById(group.Id)!; + archived.Name = "Geändert"; + Assert.Throws(() => repo.Save(archived)); + archived.IsActive = true; + Assert.Throws(() => repo.Save(archived)); + + archived.Name = "8a"; + repo.Save(archived); + archived.Name = "8b"; + repo.Save(archived); + + Assert.Equal("8b", repo.GetById(group.Id)!.Name); + } + [Fact] public void GroupRepository_Delete_LoeschtAlleAbhaengigenDatensaetzeKaskadierend() { diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 1d3fd6f..326c050 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -3,6 +3,18 @@ using LehrerApp.Core.Models; namespace LehrerApp.Data.Repositories; +internal static class ArchivedGroupWriteGuard +{ + public const string Message = + "Diese Lerngruppe ist archiviert. Bitte reaktiviere sie, bevor du Änderungen vornimmst."; + + public static void EnsureActive(LiteDbContext db, Guid groupId) + { + if (db.Groups.FindById(groupId) is { IsActive: false }) + throw new InvalidOperationException(Message); + } +} + public class StudentRepository(LiteDbContext db) : IStudentRepository { public Student? GetById(Guid id) => db.Students.FindById(id); @@ -16,8 +28,22 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository .Select(e => e.StudentId).ToHashSet(); return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList(); } + public StudentReferenceSummary GetReferenceSummary(Guid studentId) => new( + db.Memberships.Count(m => m.StudentId == studentId), + db.ExamResults.Count(r => r.StudentId == studentId), + db.Grades.Count(g => g.StudentId == studentId), + db.ReportGrades.Count(g => g.StudentId == studentId), + db.ParticipationEntries.Count(e => e.StudentId == studentId), + db.Documentation.Count(d => d.StudentId == studentId)); public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); } - public void Delete(Guid id) => db.Students.Delete(id); + public void Delete(Guid id) + { + var references = GetReferenceSummary(id); + if (references.HasReferences) + throw new InvalidOperationException( + "Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden."); + db.Students.Delete(id); + } } public class GroupRepository(LiteDbContext db) : IGroupRepository @@ -33,6 +59,16 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository .OrderBy(g => g.Name).ToList(); public void Save(LearningGroup g) { + var existing = db.Groups.FindById(g.Id); + if (existing is { IsActive: false } && !g.IsActive) + throw new InvalidOperationException(ArchivedGroupWriteGuard.Message); + if (existing is { IsActive: false } && g.IsActive + && (existing.Name != g.Name || existing.Type != g.Type || existing.SubjectId != g.SubjectId + || existing.SchoolYear != g.SchoolYear || existing.GradeLevel != g.GradeLevel + || existing.GradingSystem != g.GradingSystem || existing.HoursPerWeek != g.HoursPerWeek + || existing.IsOwnClass != g.IsOwnClass || existing.IsDifferentiated != g.IsDifferentiated)) + throw new InvalidOperationException( + "Bitte reaktiviere die Lerngruppe zuerst und nimm die Änderungen anschließend vor."); if (g.SubjectId is Guid subjectId && db.Subjects.FindById(subjectId) is null) throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr."); g.UpdatedAt = DateTime.UtcNow; @@ -40,6 +76,7 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository } public void Delete(Guid id) { + ArchivedGroupWriteGuard.EnsureActive(db, id); db.ExecuteInTransaction(() => { foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList()) @@ -121,12 +158,18 @@ public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepos db.Memberships.FindOne(e => e.StudentId == studentId && e.GroupId == groupId); public void Save(GroupMembership membership) { + ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId); var existing = GetByStudentAndGroup(membership.StudentId, membership.GroupId); if (existing is not null && existing.Id != membership.Id) throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet."); db.Memberships.Upsert(membership); } - public void Delete(Guid id) => db.Memberships.Delete(id); + public void Delete(Guid id) + { + if (db.Memberships.FindById(id) is { } membership) + ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId); + db.Memberships.Delete(id); + } } public class ExamRepository(LiteDbContext db) : IExamRepository @@ -135,9 +178,16 @@ public class ExamRepository(LiteDbContext db) : IExamRepository public List GetAll() => db.Exams.FindAll().OrderBy(e => e.Date).ToList(); public List GetByGroup(Guid groupId) => db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList(); - public void Save(Exam e) { e.UpdatedAt = DateTime.UtcNow; db.Exams.Upsert(e); } + public void Save(Exam e) + { + ArchivedGroupWriteGuard.EnsureActive(db, e.GroupId); + e.UpdatedAt = DateTime.UtcNow; + db.Exams.Upsert(e); + } public void Delete(Guid id) { + if (db.Exams.FindById(id) is { } exam) + ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList()) db.ExamResults.Delete(result.Id); db.Exams.Delete(id); @@ -152,9 +202,18 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository db.ExamResults.Find(r => r.StudentId == id).ToList(); public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) => db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId); - public void Save(ExamResult r) { r.UpdatedAt = DateTime.UtcNow; db.ExamResults.Upsert(r); } + public void Save(ExamResult r) + { + if (db.Exams.FindById(r.ExamId) is { } exam) + ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); + r.UpdatedAt = DateTime.UtcNow; + db.ExamResults.Upsert(r); + } public void SaveMany(List results) { + foreach (var examId in results.Select(r => r.ExamId).Distinct()) + if (db.Exams.FindById(examId) is { } exam) + ArchivedGroupWriteGuard.EnsureActive(db, exam.GroupId); var now = DateTime.UtcNow; foreach (var r in results) r.UpdatedAt = now; db.ExamResults.Upsert(results); @@ -178,8 +237,17 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList(); public List GetByGroup(Guid id) => db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList(); - public void Save(Grade g) => db.Grades.Upsert(g); - public void Delete(Guid id) => db.Grades.Delete(id); + public void Save(Grade g) + { + ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId); + db.Grades.Upsert(g); + } + public void Delete(Guid id) + { + if (db.Grades.FindById(id) is { } grade) + ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); + db.Grades.Delete(id); + } } public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository @@ -187,8 +255,18 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor public GradingScheme? GetByGroup(Guid groupId) => db.GradingSchemes.FindOne(s => s.GroupId == groupId); public GradingScheme? GetDefaultForType(GroupType type) => db.GradingSchemes.FindOne(s => s.GroupId == null && s.GroupType == type); - public void Save(GradingScheme s) { s.UpdatedAt = DateTime.UtcNow; db.GradingSchemes.Upsert(s); } - public void Delete(Guid id) => db.GradingSchemes.Delete(id); + public void Save(GradingScheme s) + { + if (s.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); + s.UpdatedAt = DateTime.UtcNow; + db.GradingSchemes.Upsert(s); + } + public void Delete(Guid id) + { + if (db.GradingSchemes.FindById(id)?.GroupId is Guid groupId) + ArchivedGroupWriteGuard.EnsureActive(db, groupId); + db.GradingSchemes.Delete(id); + } } public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository @@ -196,8 +274,18 @@ public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository public List GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList(); public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) => db.ReportGrades.FindOne(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == period); - public void Save(ReportGrade r) { r.UpdatedAt = DateTime.UtcNow; db.ReportGrades.Upsert(r); } - public void Delete(Guid id) => db.ReportGrades.Delete(id); + public void Save(ReportGrade r) + { + ArchivedGroupWriteGuard.EnsureActive(db, r.GroupId); + r.UpdatedAt = DateTime.UtcNow; + db.ReportGrades.Upsert(r); + } + public void Delete(Guid id) + { + if (db.ReportGrades.FindById(id) is { } grade) + ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); + db.ReportGrades.Delete(id); + } } public class UnitRepository(LiteDbContext db) : IUnitRepository @@ -205,8 +293,18 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository public Unit? GetById(Guid id) => db.Units.FindById(id); public List GetByGroup(Guid id) => db.Units.Find(u => u.GroupId == id).OrderBy(u => u.StartDate).ToList(); - public void Save(Unit u) { u.UpdatedAt = DateTime.UtcNow; db.Units.Upsert(u); } - public void Delete(Guid id) => db.Units.Delete(id); + public void Save(Unit u) + { + ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId); + u.UpdatedAt = DateTime.UtcNow; + db.Units.Upsert(u); + } + public void Delete(Guid id) + { + if (db.Units.FindById(id) is { } unit) + ArchivedGroupWriteGuard.EnsureActive(db, unit.GroupId); + db.Units.Delete(id); + } } public class LessonRepository(LiteDbContext db) : ILessonRepository @@ -218,8 +316,18 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository public List GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) => db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to) .OrderBy(l => l.Date).ToList(); - public void Save(Lesson l) { l.UpdatedAt = DateTime.UtcNow; db.Lessons.Upsert(l); } - public void Delete(Guid id) => db.Lessons.Delete(id); + public void Save(Lesson l) + { + ArchivedGroupWriteGuard.EnsureActive(db, l.GroupId); + l.UpdatedAt = DateTime.UtcNow; + db.Lessons.Upsert(l); + } + public void Delete(Guid id) + { + if (db.Lessons.FindById(id) is { } lesson) + ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId); + db.Lessons.Delete(id); + } } public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository @@ -276,9 +384,16 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe public List GetByGroup(Guid groupId) => db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList(); public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id); - public void Save(ParticipationSession s) { s.UpdatedAt = DateTime.UtcNow; db.ParticipationSessions.Upsert(s); } + public void Save(ParticipationSession s) + { + ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); + s.UpdatedAt = DateTime.UtcNow; + db.ParticipationSessions.Upsert(s); + } public void Delete(Guid id) { + if (db.ParticipationSessions.FindById(id) is { } session) + ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); db.ParticipationSessions.Delete(id); foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList()) db.ParticipationEntries.Delete(e.Id); @@ -293,15 +408,26 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor db.ParticipationEntries.Find(e => e.StudentId == studentId).ToList(); public ParticipationEntry? GetBySessionAndStudent(Guid sessionId, Guid studentId) => db.ParticipationEntries.FindOne(e => e.SessionId == sessionId && e.StudentId == studentId); - public void Save(ParticipationEntry e) { e.UpdatedAt = DateTime.UtcNow; db.ParticipationEntries.Upsert(e); } + public void Save(ParticipationEntry e) + { + if (db.ParticipationSessions.FindById(e.SessionId) is { } session) + ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); + e.UpdatedAt = DateTime.UtcNow; + db.ParticipationEntries.Upsert(e); + } public void SaveMany(List entries) { + foreach (var sessionId in entries.Select(e => e.SessionId).Distinct()) + if (db.ParticipationSessions.FindById(sessionId) is { } session) + ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); var now = DateTime.UtcNow; foreach (var e in entries) e.UpdatedAt = now; db.ParticipationEntries.Upsert(entries); } public void DeleteBySession(Guid sessionId) { + if (db.ParticipationSessions.FindById(sessionId) is { } session) + ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId); foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList()) db.ParticipationEntries.Delete(e.Id); } @@ -313,16 +439,35 @@ public class ParticipationAspectRepository(LiteDbContext db) : IParticipationAsp db.ParticipationAspects.Find(a => a.GroupId == null && a.IsActive).OrderBy(a => a.SortOrder).ToList(); public List GetByGroup(Guid groupId) => db.ParticipationAspects.Find(a => a.GroupId == groupId && a.IsActive).OrderBy(a => a.SortOrder).ToList(); - public void Save(ParticipationAspect a) { a.UpdatedAt = DateTime.UtcNow; db.ParticipationAspects.Upsert(a); } - public void Delete(Guid id) => db.ParticipationAspects.Delete(id); + public void Save(ParticipationAspect a) + { + if (a.GroupId is Guid groupId) ArchivedGroupWriteGuard.EnsureActive(db, groupId); + a.UpdatedAt = DateTime.UtcNow; + db.ParticipationAspects.Upsert(a); + } + public void Delete(Guid id) + { + if (db.ParticipationAspects.FindById(id)?.GroupId is Guid groupId) + ArchivedGroupWriteGuard.EnsureActive(db, groupId); + db.ParticipationAspects.Delete(id); + } } public class ParticipationSectionRepository(LiteDbContext db) : IParticipationSectionRepository { public List GetByGroup(Guid groupId) => db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList(); - public void Save(ParticipationSection s) => db.ParticipationSections.Upsert(s); - public void Delete(Guid id) => db.ParticipationSections.Delete(id); + public void Save(ParticipationSection s) + { + ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); + db.ParticipationSections.Upsert(s); + } + public void Delete(Guid id) + { + if (db.ParticipationSections.FindById(id) is { } section) + ArchivedGroupWriteGuard.EnsureActive(db, section.GroupId); + db.ParticipationSections.Delete(id); + } } public class SubjectRepository(LiteDbContext db) : ISubjectRepository @@ -395,13 +540,19 @@ public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepositor db.TimetableSlots.Find(s => s.GroupId == groupId).OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList(); public void Save(TimetableSlot slot) { + ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId); var occupied = db.TimetableSlots.FindAll() .FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber); if (occupied is not null && occupied.Id != slot.Id) throw new InvalidOperationException("Diese Stunde ist bereits belegt."); db.TimetableSlots.Upsert(slot); } - public void Delete(Guid id) => db.TimetableSlots.Delete(id); + public void Delete(Guid id) + { + if (db.TimetableSlots.FindById(id) is { } slot) + ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId); + db.TimetableSlots.Delete(id); + } } public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository diff --git a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs new file mode 100644 index 0000000..7c633a0 --- /dev/null +++ b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs @@ -0,0 +1,108 @@ +using System.IO.Compression; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class CreateLetterDialogViewModelTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}"); + + public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory); + + [Fact] + public void OhneVorlage_KannKeinenBriefErzeugen() + { + var student = StudentWithContact("Sehr geehrte Frau Muster,"); + + var vm = Build(student, new LetterTemplateService(_directory)); + + Assert.False(vm.CanGenerate); + Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void VerwendeteBriefanredeFehlt_BlockiertErzeugung() + { + var service = ServiceWithTemplate("Letter.Salutation"); + var vm = Build(StudentWithContact(null), service); + + Assert.False(vm.CanGenerate); + Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede")); + } + + [Fact] + public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie() + { + var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate"); + var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service); + var output = Path.Combine(_directory, "Brief.docx"); + + var generated = vm.Generate(output); + + Assert.True(generated); + Assert.True(File.Exists(output)); + using var archive = ZipFile.OpenRead(output); + using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open()); + var xml = reader.ReadToEnd(); + Assert.Contains("Sehr geehrte Frau Muster,", xml); + Assert.Contains("Lena", xml); + } + + [Fact] + public void KontaktBearbeiten_SpeichertExpliziteBriefanrede() + { + var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" }); + vm.LetterSalutation = "Sehr geehrte Frau Muster,"; + + vm.SaveCommand.Execute(null); + + Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation); + } + + private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) => + new(student, service, new FakeMemberships([]), new FakeGroups([])); + + private LetterTemplateService ServiceWithTemplate(params string[] tags) + { + var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx"); + using (var archive = ZipFile.Open(source, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("word/document.xml"); + using var writer = new StreamWriter(entry.Open()); + var controls = string.Join("", tags.Select(tag => + $"" + + "Platzhalter")); + writer.Write("" + + $"{controls}"); + } + var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}")); + service.Import(source, "Elternbrief"); + return service; + } + + private static Student StudentWithContact(string? salutation) => new() + { + FirstName = "Lena", + LastName = "Beispiel", + Contacts = + [ + new Contact + { + Name = "Frau Muster", + Relation = "Mutter", + LetterSalutation = salutation, + Street = "Hauptstraße 1", + PostalCode = "12345", + City = "Musterstadt", + }, + ], + }; + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } +} diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index c503c00..bda89e8 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -8,11 +8,20 @@ namespace LehrerApp.Desktop.Tests; public class FakeStudents(List all) : IStudentRepository { + private readonly Dictionary _references = []; public Student? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); - public List GetAll(bool includeInactive = false) => all; + public List GetAll(bool includeInactive = false) => + includeInactive ? all.ToList() : all.Where(s => s.IsActive).ToList(); public List GetByGroup(Guid groupId) => all; - public void Save(Student student) { } - public void Delete(Guid id) { } + public StudentReferenceSummary GetReferenceSummary(Guid studentId) => + _references.GetValueOrDefault(studentId, new StudentReferenceSummary(0, 0, 0, 0, 0, 0)); + public void SetReferenceSummary(Guid studentId, StudentReferenceSummary summary) => _references[studentId] = summary; + public void Save(Student student) { all.RemoveAll(s => s.Id == student.Id); all.Add(student); } + public void Delete(Guid id) + { + if (GetReferenceSummary(id).HasReferences) throw new InvalidOperationException(); + all.RemoveAll(s => s.Id == id); + } } public class FakeMemberships(List all) : IGroupMembershipRepository diff --git a/LehrerApp.Desktop.Tests/GenderAvatarDisplayTests.cs b/LehrerApp.Desktop.Tests/GenderAvatarDisplayTests.cs new file mode 100644 index 0000000..bc712c6 --- /dev/null +++ b/LehrerApp.Desktop.Tests/GenderAvatarDisplayTests.cs @@ -0,0 +1,29 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public class GenderAvatarDisplayTests +{ + [Theory] + [InlineData(Gender.M, "M")] + [InlineData(Gender.W, "W")] + [InlineData(Gender.D, "D")] + public void VerwendetEindeutigeBeschriftung(Gender gender, string expected) => + Assert.Equal(expected, GenderAvatarDisplay.Label(gender)); + + [Fact] + public void UnbekanntesGeschlechtHatNeutralenFallback() + { + Assert.Equal("?", GenderAvatarDisplay.Label(null)); + Assert.Contains("nicht angegeben", GenderAvatarDisplay.Tooltip(null)); + } + + [Theory] + [InlineData(Gender.M)] + [InlineData(Gender.W)] + [InlineData(Gender.D)] + public void AuswahlwertKannVerlustfreiZurueckgewandeltWerden(Gender gender) => + Assert.Equal(gender, GenderAvatarDisplay.FromOption(GenderAvatarDisplay.ToOption(gender))); +} diff --git a/LehrerApp.Desktop.Tests/GroupRolloverDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupRolloverDialogViewModelTests.cs new file mode 100644 index 0000000..498001f --- /dev/null +++ b/LehrerApp.Desktop.Tests/GroupRolloverDialogViewModelTests.cs @@ -0,0 +1,74 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class GroupRolloverDialogViewModelTests +{ + [Fact] + public void Vorauswahl_NimmtNurAktiveSchuelerAmSchuljahresende() + { + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26", GradeLevel = 8 }; + var fullYearStudent = Student("Ganzjahr"); + var h1Student = Student("Halbjahr"); + var withdrawnStudent = Student("Ausgetreten"); + var inactiveStudent = Student("Inaktiv", active: false); + var membershipList = new List + { + Membership(group, fullYearStudent, MembershipPeriod.FullYear), + Membership(group, h1Student, MembershipPeriod.H1Only), + Membership(group, withdrawnStudent, MembershipPeriod.FullYear, new DateOnly(2026, 5, 1)), + Membership(group, inactiveStudent, MembershipPeriod.FullYear), + }; + var groups = new FakeGroups([group]); + var memberships = new FakeMemberships(membershipList); + var schemes = new FakeSchemes(); + var schoolYears = new SchoolYearService(); + var vm = new GroupRolloverDialogViewModel(group, + new FakeStudents([fullYearStudent, h1Student, withdrawnStudent, inactiveStudent]), + memberships, schemes, schoolYears, + new GroupRolloverService(groups, memberships, schemes, schoolYears)); + + Assert.True(vm.Students.Single(s => s.FullName.Contains("Ganzjahr")).IsSelected); + Assert.False(vm.Students.Single(s => s.FullName.Contains("Halbjahr")).IsSelected); + Assert.False(vm.Students.Single(s => s.FullName.Contains("Ausgetreten")).IsSelected); + var inactive = vm.Students.Single(s => s.FullName.Contains("Inaktiv")); + Assert.False(inactive.IsSelected); + Assert.False(inactive.CanSelect); + Assert.Equal(1, vm.SelectedCount); + } + + [Fact] + public void Dialog_SchlaegtNaechstesSchuljahrUndNaechsteStufeVor() + { + var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26", GradeLevel = 8 }; + var student = Student("Ganzjahr"); + var membershipList = new List { Membership(group, student, MembershipPeriod.FullYear) }; + var groups = new FakeGroups([group]); + var memberships = new FakeMemberships(membershipList); + var schemes = new FakeSchemes(); + var schoolYears = new SchoolYearService(); + + var vm = new GroupRolloverDialogViewModel(group, new FakeStudents([student]), memberships, + schemes, schoolYears, new GroupRolloverService(groups, memberships, schemes, schoolYears)); + + Assert.Equal("2026/27", vm.TargetSchoolYear); + Assert.Equal(9, vm.GradeLevel); + Assert.True(vm.ArchiveSource); + } + + private static Student Student(string lastName, bool active = true) => + new() { FirstName = "Test", LastName = lastName, IsActive = active }; + + private static GroupMembership Membership(LearningGroup group, Student student, + MembershipPeriod period, DateOnly? leftAt = null) => new() + { + GroupId = group.Id, + StudentId = student.Id, + Period = period, + JoinedAt = new DateOnly(2025, 8, 1), + LeftAt = leftAt, + }; +} diff --git a/LehrerApp.Desktop.Tests/ManageStudentDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ManageStudentDialogViewModelTests.cs new file mode 100644 index 0000000..3f1564c --- /dev/null +++ b/LehrerApp.Desktop.Tests/ManageStudentDialogViewModelTests.cs @@ -0,0 +1,50 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public class ManageStudentDialogViewModelTests +{ + [Fact] + public void VerknuepfteDatenSperrenEndgueltigesLoeschenAberNichtDeaktivieren() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var students = new FakeStudents([student]); + students.SetReferenceSummary(student.Id, new StudentReferenceSummary(1, 2, 0, 0, 3, 1)); + var vm = new ManageStudentDialogViewModel(students, student); + + Assert.False(vm.CanDelete); + Assert.Equal(4, vm.ReferenceItems.Count); + + vm.DeactivateCommand.Execute(null); + + Assert.False(student.IsActive); + Assert.Equal(StudentManagementResult.Deactivated, vm.Result); + } + + [Fact] + public void UnverknuepfterSchuelerKannEndgueltigGeloeschtWerden() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var students = new FakeStudents([student]); + var vm = new ManageStudentDialogViewModel(students, student); + + vm.DeletePermanentlyCommand.Execute(null); + + Assert.Equal(StudentManagementResult.Deleted, vm.Result); + Assert.Null(students.GetById(student.Id)); + } + + [Fact] + public void DeaktivierterSchuelerKannWiederAktiviertWerden() + { + var student = new Student { FirstName = "Anna", LastName = "Beispiel", IsActive = false }; + var vm = new ManageStudentDialogViewModel(new FakeStudents([student]), student); + + vm.ReactivateCommand.Execute(null); + + Assert.True(student.IsActive); + Assert.Equal(StudentManagementResult.Reactivated, vm.Result); + } +} diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 67263b2..3d9aba5 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -24,7 +24,8 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), - new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties()); + new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), + new LetterTemplateService(tempPath)); } [Fact] @@ -100,7 +101,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), - new FakeSupervisionDuties()); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath)); vm.SelectedStateName = "Bayern"; @@ -122,7 +123,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties()); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath)); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; @@ -148,7 +149,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties()); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath)); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; diff --git a/LehrerApp.Desktop.Tests/StudentListViewModelTests.cs b/LehrerApp.Desktop.Tests/StudentListViewModelTests.cs new file mode 100644 index 0000000..ca757eb --- /dev/null +++ b/LehrerApp.Desktop.Tests/StudentListViewModelTests.cs @@ -0,0 +1,60 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Students; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public class StudentListViewModelTests +{ + private readonly Student _anna = new() { FirstName = "Anna", LastName = "Beispiel", Gender = Gender.W }; + private readonly Student _ben = new() { FirstName = "Ben", LastName = "Muster", Gender = Gender.M }; + private readonly LearningGroup _group = new() { Name = "Mathematik 7a" }; + + [Fact] + public void SucheFindetSchuelerWeiterhinUeberNamen() + { + var vm = BuildViewModel(); + + vm.SearchText = "Anna"; + + Assert.Single(vm.Students); + Assert.Equal(_anna.Id, vm.Students[0].Id); + } + + [Fact] + public void SucheFindetSchuelerUeberLerngruppe() + { + var vm = BuildViewModel(); + + vm.SearchText = "7a"; + + Assert.Single(vm.Students); + Assert.Equal(_ben.Id, vm.Students[0].Id); + } + + [Fact] + public void LeereSucheZeigtAlleSchueler() + { + var vm = BuildViewModel(); + + vm.SearchText = " "; + + Assert.Equal(2, vm.Students.Count); + } + + [Fact] + public void ListeneintraegeZeigenAvatarAusGespeichertemGeschlecht() + { + var vm = BuildViewModel(); + + Assert.Equal("W", vm.Students.Single(s => s.Id == _anna.Id).AvatarLabel); + Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel); + } + + private StudentListViewModel BuildViewModel() => new( + new FakeStudents([_anna, _ben]), + new FakeGroups([_group]), + new FakeMemberships([ + new GroupMembership { StudentId = _ben.Id, GroupId = _group.Id }, + ])); +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index cd26652..9a69b3a 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -140,10 +140,12 @@ public static class AppBootstrapper // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(_ => new SchoolCalendarSettingsService(appData)); services.AddSingleton(_ => new PeriodScheduleService(appData)); services.AddSingleton(_ => new WorkloadSettingsService(appData)); + services.AddSingleton(_ => new LetterTemplateService(appData)); // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── services.AddSingleton(_ => new EventQueue(queuePath)); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs index 58243ce..793b85a 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs @@ -37,6 +37,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject [ObservableProperty] private bool _showAsPoints = true; [ObservableProperty] private GradeOverviewRow? _selectedRow; [ObservableProperty] private int _rebuildColumnsSignal; + [ObservableProperty] private bool _isReadOnly; public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15; @@ -64,13 +65,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject } public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType, - string groupLabel, string schoolYear) + string groupLabel, string schoolYear, bool isReadOnly = false) { _groupId = groupId; _gradingSystem = gradingSystem; _groupType = groupType; _groupLabel = groupLabel; _schoolYear = schoolYear; + IsReadOnly = isReadOnly; ShowAsPoints = gradingSystem == GradingSystem.Points0To15; OnPropertyChanged(nameof(CanTogglePointsView)); Recompute(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupRolloverViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupRolloverViewModel.cs new file mode 100644 index 0000000..7311bd3 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupRolloverViewModel.cs @@ -0,0 +1,164 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public partial class GroupRolloverDialogViewModel : ObservableObject +{ + private readonly LearningGroup _source; + private readonly GroupRolloverService _rollover; + private readonly SchoolYearService _schoolYears; + + [ObservableProperty] private string _name; + [ObservableProperty] private string _targetSchoolYear; + [ObservableProperty] private int _gradeLevel; + [ObservableProperty] private bool _copyGradingScheme; + [ObservableProperty] private bool _archiveSource = true; + [ObservableProperty] private string _nameError = ""; + [ObservableProperty] private string _schoolYearError = ""; + [ObservableProperty] private string _selectionError = ""; + [ObservableProperty] private string _generalError = ""; + + public string SourceSummary => $"{_source.Name} · {_source.SchoolYear} · Stufe {_source.GradeLevel}"; + public bool HasGradingScheme { get; } + public int SelectedCount => Students.Count(s => s.IsSelected); + public string SelectedCountText => $"{SelectedCount} von {Students.Count} Schülern ausgewählt"; + public ObservableCollection Students { get; } = []; + public LearningGroup? Result { get; private set; } + + public GroupRolloverDialogViewModel(LearningGroup source, + IStudentRepository students, IGroupMembershipRepository memberships, + IGradingSchemeRepository gradingSchemes, SchoolYearService schoolYears, + GroupRolloverService rollover) + { + _source = source; + _rollover = rollover; + _schoolYears = schoolYears; + _name = source.Name; + _targetSchoolYear = NextSchoolYear(source.SchoolYear, schoolYears); + _gradeLevel = Math.Min(13, source.GradeLevel + 1); + HasGradingScheme = gradingSchemes.GetByGroup(source.Id) is not null; + _copyGradingScheme = HasGradingScheme; + + var sourceEnd = schoolYears.SchoolYearEnd(source.SchoolYear); + foreach (var membership in memberships.GetByGroup(source.Id) + .Select(m => (Membership: m, Student: students.GetById(m.StudentId))) + .Where(x => x.Student is not null) + .OrderBy(x => x.Student!.LastName).ThenBy(x => x.Student!.FirstName)) + { + var canSelect = membership.Student!.IsActive; + var isPreselected = canSelect + && GroupMembershipService.IsActiveOn(membership.Membership, sourceEnd); + var item = new GroupRolloverStudentItem( + membership.Student, membership.Membership, isPreselected, canSelect); + item.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(GroupRolloverStudentItem.IsSelected)) return; + SelectionError = ""; + OnPropertyChanged(nameof(SelectedCount)); + OnPropertyChanged(nameof(SelectedCountText)); + }; + Students.Add(item); + } + } + + [RelayCommand] + private void Save() + { + NameError = ""; + SchoolYearError = ""; + SelectionError = ""; + GeneralError = ""; + var valid = true; + + if (string.IsNullOrWhiteSpace(Name)) + { + NameError = "Gruppenname erforderlich."; + valid = false; + } + try + { + var targetText = TargetSchoolYear.Trim(); + var targetStart = _schoolYears.SchoolYearStart(targetText); + if (targetText != _schoolYears.FormatSchoolYear(targetStart.Year)) + { + SchoolYearError = "Format JJJJ/JJ, z.B. 2027/28."; + valid = false; + } + else if (targetStart <= _schoolYears.SchoolYearStart(_source.SchoolYear)) + { + SchoolYearError = "Das Zielschuljahr muss nach dem bisherigen liegen."; + valid = false; + } + } + catch (Exception ex) when (ex is FormatException or ArgumentOutOfRangeException or IndexOutOfRangeException) + { + SchoolYearError = "Format JJJJ/JJ, z.B. 2027/28."; + valid = false; + } + if (SelectedCount == 0) + { + SelectionError = "Bitte mindestens einen Schüler auswählen."; + valid = false; + } + if (!valid) return; + + try + { + Result = _rollover.RollOver(_source, new GroupRolloverRequest( + Name, TargetSchoolYear.Trim(), GradeLevel, + Students.Where(s => s.IsSelected).Select(s => s.MembershipId).ToList(), + CopyGradingScheme, ArchiveSource)); + } + catch (Exception ex) when (ex is InvalidOperationException or FormatException or ArgumentOutOfRangeException) + { + GeneralError = ex.Message; + } + } + + private static string NextSchoolYear(string source, SchoolYearService schoolYears) + { + var start = schoolYears.SchoolYearStart(source); + return schoolYears.FormatSchoolYear(start.Year + 1); + } +} + +public partial class GroupRolloverStudentItem : ObservableObject +{ + [ObservableProperty] private bool _isSelected; + + public Guid MembershipId { get; } + public string FullName { get; } + public string NiveauText { get; } + public string MembershipStatus { get; } + public bool CanSelect { get; } + + public GroupRolloverStudentItem(Student student, GroupMembership membership, + bool isSelected, bool canSelect) + { + MembershipId = membership.Id; + FullName = student.FullName; + NiveauText = membership.Niveau?.ToString() ?? "–"; + CanSelect = canSelect; + _isSelected = isSelected; + MembershipStatus = BuildStatus(student, membership, isSelected); + } + + partial void OnIsSelectedChanged(bool value) + { + if (!CanSelect && value) IsSelected = false; + } + + private static string BuildStatus(Student student, GroupMembership membership, bool isPreselected) + { + if (!student.IsActive) return "Schüler inaktiv · nicht übernehmbar"; + if (isPreselected) return "Bis Schuljahresende aktiv · vorausgewählt"; + if (membership.LeftAt is { } leftAt) return $"Ausgetreten am {leftAt:dd.MM.yyyy} · nicht vorausgewählt"; + if (membership.Period == MembershipPeriod.H1Only) return "Nur 1. Halbjahr · nicht vorausgewählt"; + return "Am Schuljahresende nicht aktiv · nicht vorausgewählt"; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index fca9cc8..064b263 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Students; using System.Collections.ObjectModel; namespace LehrerApp.Desktop.ViewModels.Groups; @@ -17,6 +18,7 @@ public partial class GroupListViewModel : ObservableObject public Action? OnNavigateToDetail { get; set; } public Func? OnAddGroup { get; set; } public Func? OnEditGroup { get; set; } + public Func>? OnRollOverGroup { get; set; } public Func>? OnConfirmDelete { get; set; } [ObservableProperty] private string _selectedSchoolYear = ""; @@ -53,6 +55,7 @@ public partial class GroupListViewModel : ObservableObject OnPropertyChanged(nameof(SelectedGroupSubtitle)); NavigateToSectionCommand.NotifyCanExecuteChanged(); EditGroupCommand.NotifyCanExecuteChanged(); + RollOverGroupCommand.NotifyCanExecuteChanged(); ToggleArchiveCommand.NotifyCanExecuteChanged(); DeleteGroupCommand.NotifyCanExecuteChanged(); } @@ -85,7 +88,7 @@ public partial class GroupListViewModel : ObservableObject } [RelayCommand] private void Refresh() => LoadGroups(); - [RelayCommand(CanExecute = nameof(HasSelectedGroup))] + [RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] private async Task EditGroup() { if (SelectedGroup is null || OnEditGroup is null) return; @@ -95,6 +98,20 @@ public partial class GroupListViewModel : ObservableObject SelectedGroup = Groups.FirstOrDefault(g => g.Id == id); } + [RelayCommand(CanExecute = nameof(HasSelectedGroup))] + private async Task RollOverGroup() + { + if (SelectedGroup is null || OnRollOverGroup is null) return; + var targetId = await OnRollOverGroup(SelectedGroup.Id); + if (targetId is null) return; + var target = _groups.GetById(targetId.Value); + if (target is null) return; + if (!SchoolYears.Contains(target.SchoolYear)) SchoolYears.Insert(0, target.SchoolYear); + SelectedSchoolYear = target.SchoolYear; + LoadGroups(); + SelectedGroup = Groups.FirstOrDefault(g => g.Id == target.Id); + } + [RelayCommand(CanExecute = nameof(HasSelectedGroup))] private void ToggleArchive() { @@ -106,7 +123,7 @@ public partial class GroupListViewModel : ObservableObject LoadGroups(); } - [RelayCommand(CanExecute = nameof(HasSelectedGroup))] + [RelayCommand(CanExecute = nameof(CanEditSelectedGroup))] private async Task DeleteGroup() { if (SelectedGroup is null || OnConfirmDelete is null) return; @@ -124,6 +141,7 @@ public partial class GroupListViewModel : ObservableObject } private bool HasSelectedGroup() => SelectedGroup is not null; + private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true; } public class GroupListItem @@ -175,9 +193,17 @@ public partial class GroupDetailViewModel : ObservableObject // bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann // kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen. public bool IsDifferentiated => Group?.IsDifferentiated ?? false; + public bool IsReadOnly => Group is { IsActive: false }; + public bool IsEditable => Group is { IsActive: true }; public string SubjectName { get; private set; } = ""; - partial void OnGroupChanged(LearningGroup? value) => OnPropertyChanged(nameof(IsDifferentiated)); + partial void OnGroupChanged(LearningGroup? value) + { + OnPropertyChanged(nameof(IsDifferentiated)); + OnPropertyChanged(nameof(IsReadOnly)); + OnPropertyChanged(nameof(IsEditable)); + NotifyWriteCommands(); + } partial void OnShowFormerStudentsChanged(bool value) => LoadStudents(); public ObservableCollection Students { get; } = []; @@ -194,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject public Func>? OnConfirmDeleteExam { get; set; } public Func? OnGradeExam { get; set; } public Func? OnEvaluateExam { get; set; } + public Func>? OnConfirmReactivate { get; set; } public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, IGroupMembershipRepository memberships, ISubjectRepository subjects, @@ -221,9 +248,9 @@ public partial class GroupDetailViewModel : ObservableObject $"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15")}"; LoadStudents(); ReloadExams(); - ParticipationTab.Initialize(Group.Id, Group.SchoolYear); - GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear); - PlanningTab.Initialize(Group.Id); + ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly); + GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly); + PlanningTab.Initialize(Group.Id, IsReadOnly); } private void ReloadExams() @@ -247,7 +274,11 @@ public partial class GroupDetailViewModel : ObservableObject { memberships.TryGetValue(s.Id, out var membership); if (membership is null) continue; - var summary = new StudentSummary(s, membership, today) { OnChanged = SaveStudentNiveau }; + var summary = new StudentSummary(s, membership, today) + { + OnChanged = SaveStudentNiveau, + CanEdit = IsEditable, + }; if (summary.IsFormer && !ShowFormerStudents) continue; Students.Add(summary); } @@ -255,14 +286,14 @@ public partial class GroupDetailViewModel : ObservableObject private void SaveStudentNiveau(StudentSummary summary) { - if (Group is null) return; + if (!IsEditable || Group is null) return; var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id); if (membership is null) return; membership.Niveau = summary.Niveau; _memberships.Save(membership); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsEditableGroup))] private async Task AddStudent() { if (OnAddStudent is null) return; @@ -274,7 +305,7 @@ public partial class GroupDetailViewModel : ObservableObject } } - [RelayCommand(CanExecute = nameof(HasSelectedStudent))] + [RelayCommand(CanExecute = nameof(CanEditSelectedStudent))] private async Task WithdrawStudent() { if (SelectedStudent is null || OnWithdrawStudent is null) return; @@ -284,7 +315,7 @@ public partial class GroupDetailViewModel : ObservableObject ParticipationTab.RefreshCurrentGrid(); } - [RelayCommand(CanExecute = nameof(CanReinstateSelectedStudent))] + [RelayCommand(CanExecute = nameof(CanEditAndReinstateSelectedStudent))] private void ReinstateStudent() { if (Group is null || SelectedStudent is null) return; @@ -303,10 +334,10 @@ public partial class GroupDetailViewModel : ObservableObject ReinstateStudentCommand.NotifyCanExecuteChanged(); } - private bool HasSelectedStudent() => SelectedStudent is not null; - private bool CanReinstateSelectedStudent() => SelectedStudent?.HasExitDate == true; + private bool CanEditSelectedStudent() => IsEditable && SelectedStudent is not null; + private bool CanEditAndReinstateSelectedStudent() => IsEditable && SelectedStudent?.HasExitDate == true; - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsEditableGroup))] private async Task AddExam() { if (Group is null || OnAddExam is null) return; @@ -314,7 +345,7 @@ public partial class GroupDetailViewModel : ObservableObject if (saved) ReloadExams(); } - [RelayCommand(CanExecute = nameof(HasSelectedExam))] + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task EditExam() { if (SelectedExam is null || OnEditExam is null) return; @@ -329,7 +360,7 @@ public partial class GroupDetailViewModel : ObservableObject } } - [RelayCommand(CanExecute = nameof(HasSelectedExam))] + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task GradeExam() { if (SelectedExam is null || OnGradeExam is null) return; @@ -338,7 +369,7 @@ public partial class GroupDetailViewModel : ObservableObject await OnGradeExam(exam); } - [RelayCommand(CanExecute = nameof(HasSelectedExam))] + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task EvaluateExam() { if (SelectedExam is null || OnEvaluateExam is null) return; @@ -347,7 +378,7 @@ public partial class GroupDetailViewModel : ObservableObject await OnEvaluateExam(exam); } - [RelayCommand(CanExecute = nameof(HasSelectedExam))] + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task DuplicateExam() { if (SelectedExam is null || OnDuplicateExam is null) return; @@ -357,7 +388,7 @@ public partial class GroupDetailViewModel : ObservableObject if (saved) ReloadExams(); } - [RelayCommand(CanExecute = nameof(HasSelectedExam))] + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task DeleteExam() { if (SelectedExam is null || OnConfirmDeleteExam is null) return; @@ -368,7 +399,7 @@ public partial class GroupDetailViewModel : ObservableObject ReloadExams(); } - [RelayCommand(CanExecute = nameof(HasSelectedExam))] + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private void AdvanceExamStatus() { if (SelectedExam is null) return; @@ -381,7 +412,7 @@ public partial class GroupDetailViewModel : ObservableObject }); } - [RelayCommand(CanExecute = nameof(HasSelectedExam))] + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private void SetExamStatus(ExamStatus status) { if (SelectedExam is null) return; @@ -409,7 +440,35 @@ public partial class GroupDetailViewModel : ObservableObject SetExamStatusCommand.NotifyCanExecuteChanged(); } - private bool HasSelectedExam() => SelectedExam is not null; + private bool CanEditSelectedExam() => IsEditable && SelectedExam is not null; + private bool IsEditableGroup() => IsEditable; + + [RelayCommand(CanExecute = nameof(IsReadOnlyGroup))] + private async Task ReactivateGroup() + { + if (Group is null || OnConfirmReactivate is null || !await OnConfirmReactivate()) return; + Group.IsActive = true; + _groups.Save(Group); + LoadGroup(Group.Id); + } + + private bool IsReadOnlyGroup() => IsReadOnly; + + private void NotifyWriteCommands() + { + AddStudentCommand.NotifyCanExecuteChanged(); + WithdrawStudentCommand.NotifyCanExecuteChanged(); + ReinstateStudentCommand.NotifyCanExecuteChanged(); + AddExamCommand.NotifyCanExecuteChanged(); + EditExamCommand.NotifyCanExecuteChanged(); + GradeExamCommand.NotifyCanExecuteChanged(); + EvaluateExamCommand.NotifyCanExecuteChanged(); + DuplicateExamCommand.NotifyCanExecuteChanged(); + DeleteExamCommand.NotifyCanExecuteChanged(); + AdvanceExamStatusCommand.NotifyCanExecuteChanged(); + SetExamStatusCommand.NotifyCanExecuteChanged(); + ReactivateGroupCommand.NotifyCanExecuteChanged(); + } [RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); } } @@ -448,6 +507,9 @@ public partial class StudentSummary : ObservableObject public bool HasExitDate { get; } public string MembershipStatus { get; } public string WithdrawActionLabel => HasExitDate ? "Austrittsdatum ändern" : "Austragen"; + public string AvatarLabel { get; } + public string AvatarColor { get; } + public string AvatarTooltip { get; } [ObservableProperty] private Niveau? _niveau; @@ -458,11 +520,15 @@ public partial class StudentSummary : ObservableObject } public Action? OnChanged { get; set; } + public bool CanEdit { get; init; } = true; public StudentSummary(Core.Models.Student s, GroupMembership? membership, DateOnly? today = null) { Id = s.Id; FullName = s.FullName; + AvatarLabel = GenderAvatarDisplay.Label(s.Gender); + AvatarColor = GenderAvatarDisplay.Color(s.Gender); + AvatarTooltip = GenderAvatarDisplay.Tooltip(s.Gender); PeriodLabel = membership is null ? "" : BuildPeriodLabel(membership); HasExitDate = membership?.LeftAt.HasValue == true; IsFormer = membership?.LeftAt is { } leftAt diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index 6a3df2b..4091c9c 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -37,6 +37,7 @@ public partial class ParticipationTabViewModel : ObservableObject [ObservableProperty] private bool _hasCompetencyCatalog; [ObservableProperty] private bool _studentCompetencyRatingsVisible; [ObservableProperty] private int _rebuildColumnsSignal; + [ObservableProperty] private bool _isReadOnly; public string SelectedSessionDisplay => SelectedSession?.Display ?? ""; public List ActiveCompetencyCodes { get; private set; } = []; @@ -67,10 +68,11 @@ public partial class ParticipationTabViewModel : ObservableObject _competencyDomains = competencyDomains; } - public void Initialize(Guid groupId, string schoolYear) + public void Initialize(Guid groupId, string schoolYear, bool isReadOnly = false) { _groupId = groupId; _schoolYear = schoolYear; + IsReadOnly = isReadOnly; var group = _groups.GetById(groupId); _subjectId = group?.SubjectId; @@ -160,6 +162,7 @@ public partial class ParticipationTabViewModel : ObservableObject private void SaveRating(Guid sessionId, Guid studentId, string key, int? value) { + if (IsReadOnly) return; var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { @@ -187,6 +190,7 @@ public partial class ParticipationTabViewModel : ObservableObject private void SaveHomework(Guid sessionId, Guid studentId, HomeworkStatus? value) { + if (IsReadOnly) return; var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; entry.Homework = value; @@ -196,6 +200,7 @@ public partial class ParticipationTabViewModel : ObservableObject private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value) { + if (IsReadOnly) return; var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; entry.Attendance = value; diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 32820ce..403eaf1 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -39,6 +39,7 @@ public partial class PlanningTabViewModel : ObservableObject [ObservableProperty] private UnitSummary? _selectedUnit; [ObservableProperty] private LessonSummary? _selectedLesson; + [ObservableProperty] private bool _isReadOnly; // Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen // Gruppenwechsel/Laden kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal @@ -74,9 +75,10 @@ public partial class PlanningTabViewModel : ObservableObject _subjects = subjects; _competencyDomains = competencyDomains; } - public void Initialize(Guid groupId) + public void Initialize(Guid groupId, bool isReadOnly = false) { _groupId = groupId; + IsReadOnly = isReadOnly; var group = _groups.GetById(groupId); SubjectId = group?.SubjectId; GradeLevel = group?.GradeLevel ?? 0; diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index 03d504e..c7cfb12 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -64,7 +64,7 @@ public partial class MainWindowViewModel : ObservableObject { NavItem.Dashboard => GetDashboard(), NavItem.Groups => _services.GetRequiredService(), - NavItem.Students => _services.GetRequiredService(), + NavItem.Students => GetStudents(), NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" }, NavItem.Planner => GetTimetable(), NavItem.Workload => GetWorkload(), @@ -80,6 +80,14 @@ public partial class MainWindowViewModel : ObservableObject return dashboard; } + private StudentListViewModel GetStudents() + { + var students = _services.GetRequiredService(); + students.SelectedStudent = null; + students.LoadStudents(); + return students; + } + private TimetableViewModel GetTimetable() { var timetable = _services.GetRequiredService(); @@ -120,9 +128,12 @@ public partial class MainWindowViewModel : ObservableObject { ActiveNavItem = NavItem.Students; var vm = _services.GetRequiredService(); + vm.OnReturnToStudentList = NavigateToStudents; vm.LoadStudent(studentId); CurrentPage = vm; } + + public void NavigateToStudents() => NavigateTo(NavItem.Students); } public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 7c4a4b6..d933807 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -29,6 +29,7 @@ public partial class SettingsViewModel : ObservableObject private readonly IDocumentationRepository _documentation; private readonly IStudentRepository _students; private readonly IShorthandCodeRepository _shorthandCodes; + private readonly LetterTemplateService _letterTemplates; [ObservableProperty] private int _activeTabIndex; @@ -67,6 +68,13 @@ public partial class SettingsViewModel : ObservableObject public List GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"]; public ObservableCollection GradingKeyTemplateList { get; } = []; + // ── Word-Briefvorlagen (7.1.4 / 11.5) ─────────────────────────────────── + + [ObservableProperty] private string _letterTemplateStatus = ""; + public ObservableCollection LetterTemplateList { get; } = []; + public IReadOnlyList SupportedLetterPlaceholders => + LetterTemplateService.SupportedPlaceholders; + // ── Gewichtungsschema-Voreinstellungen (2.3.3) ─────────────────────────── [ObservableProperty] private GradingSchemeEditItem _classScheme = null!; @@ -160,7 +168,7 @@ public partial class SettingsViewModel : ObservableObject IDocumentationRepository documentation, IStudentRepository students, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, - ISupervisionDutyRepository supervisionDuties) + ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates) { _subjects = subjects; _domainRepo = domainRepo; @@ -179,6 +187,7 @@ public partial class SettingsViewModel : ObservableObject _calendarSettings = calendarSettings; _periodSchedule = periodSchedule; _supervisionDuties = supervisionDuties; + _letterTemplates = letterTemplates; LoadSubjects(); LoadShorthandCodes(); LoadGradingKeyTemplates(); @@ -193,6 +202,55 @@ public partial class SettingsViewModel : ObservableObject LoadSchoolHolidays(); LoadPeriodTimes(); LoadSupervisionDuties(); + LoadLetterTemplates(); + } + + // ── 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 ───────────────────────────── @@ -929,6 +987,45 @@ public class BackupListItem(BackupInfo info) 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 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; diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs new file mode 100644 index 0000000..263b746 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -0,0 +1,182 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Students; + +public partial class CreateLetterDialogViewModel : ObservableObject +{ + private readonly Student _student; + private readonly LetterTemplateService _templates; + + [ObservableProperty] private LetterTemplateChoice? _selectedTemplate; + [ObservableProperty] private LetterContactChoice? _selectedContact; + [ObservableProperty] private LetterGroupChoice? _selectedGroup; + [ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now; + [ObservableProperty] private string _generationError = ""; + [ObservableProperty] private bool _canGenerate; + + public string StudentName => _student.FullName; + public ObservableCollection Templates { get; } = []; + public ObservableCollection Contacts { get; } = []; + public ObservableCollection Groups { get; } = []; + public ObservableCollection Issues { get; } = []; + public bool HasIssues => Issues.Count > 0; + public bool HasNoTemplates => Templates.Count == 0; + public bool HasNoContacts => Contacts.Count == 0; + public string SuggestedFileName => SanitizeFileName( + $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.docx"); + + public CreateLetterDialogViewModel(Student student, LetterTemplateService templates, + IGroupMembershipRepository memberships, IGroupRepository groups) + { + _student = student; + _templates = templates; + + foreach (var template in templates.GetTemplates()) + Templates.Add(new LetterTemplateChoice(template)); + foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) + Contacts.Add(new LetterContactChoice(contact)); + foreach (var membership in memberships.GetByStudent(student.Id)) + { + var group = groups.GetById(membership.GroupId); + if (group is not null) Groups.Add(new LetterGroupChoice(group)); + } + + SelectedTemplate = Templates.FirstOrDefault(); + SelectedContact = Contacts.FirstOrDefault(); + SelectedGroup = Groups.FirstOrDefault(); + RefreshValidation(); + } + + partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) + { + OnPropertyChanged(nameof(SuggestedFileName)); + RefreshValidation(); + } + partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); + partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation(); + partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation(); + + public bool Generate(string outputPath) + { + RefreshValidation(); + if (!CanGenerate || SelectedTemplate is null) return false; + GenerationError = ""; + try + { + _templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues()); + return true; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) + { + GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}"; + return false; + } + } + + private void RefreshValidation() + { + Issues.Clear(); + GenerationError = ""; + + if (SelectedTemplate is null) + Issues.Add(new("Bitte zuerst in den Einstellungen eine DOCX-Briefvorlage importieren.", true)); + if (SelectedContact is null) + Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true)); + if (LetterDate is null) + Issues.Add(new("Bitte ein Briefdatum auswählen.", true)); + + if (SelectedTemplate is not null) + { + var validation = _templates.Validate(SelectedTemplate.Model); + foreach (var issue in validation.Issues) + Issues.Add(new(issue.Message, issue.Severity is TemplateIssueSeverity.StrongWarning or TemplateIssueSeverity.Error)); + + var tags = validation.Tags.ToHashSet(StringComparer.Ordinal); + var values = BuildValues(); + foreach (var tag in tags.Where(t => values.TryGetValue(t, out var value) && string.IsNullOrWhiteSpace(value))) + { + var message = tag switch + { + "Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.", + "Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" => + $"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.", + "Group.Name" or "SchoolYear" => "Die Vorlage verwendet Gruppendaten; bitte eine Lerngruppe auswählen.", + _ => $"Für das Vorlagenfeld „{tag}“ ist kein Wert vorhanden.", + }; + Issues.Add(new(message, true)); + } + } + + CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null + && Issues.Count == 0; + OnPropertyChanged(nameof(HasIssues)); + } + + private IReadOnlyDictionary BuildValues() + { + var contact = SelectedContact?.Model; + var group = SelectedGroup?.Model; + var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City } + .Where(v => !string.IsNullOrWhiteSpace(v))); + var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine } + .Where(v => !string.IsNullOrWhiteSpace(v))); + var date = LetterDate is null ? null : DateOnly.FromDateTime(LetterDate.Value.LocalDateTime) + .ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE")); + + return new Dictionary + { + ["Student.FirstName"] = _student.FirstName, + ["Student.LastName"] = _student.LastName, + ["Contact.Name"] = contact?.Name, + ["Contact.Address"] = address, + ["Contact.Street"] = contact?.Street, + ["Contact.PostalCode"] = contact?.PostalCode, + ["Contact.City"] = contact?.City, + ["Letter.Salutation"] = contact?.LetterSalutation, + ["Group.Name"] = group?.Name, + ["SchoolYear"] = group?.SchoolYear, + ["CurrentDate"] = date, + }; + } + + private static string SanitizeFileName(string value) + { + foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); + return value; + } +} + +public sealed class LetterTemplateChoice +{ + public LetterTemplateInfo Model { get; } + public string Name => Model.Name; + public LetterTemplateChoice(LetterTemplateInfo model) => Model = model; +} + +public sealed class LetterContactChoice +{ + public Contact Model { get; } + public string Display => string.IsNullOrWhiteSpace(Model.Relation) + ? Model.Name + : $"{Model.Name} · {Model.Relation}"; + public LetterContactChoice(Contact model) => Model = model; +} + +public sealed class LetterGroupChoice +{ + public LearningGroup Model { get; } + public string Display => $"{Model.Name} · {Model.SchoolYear}"; + public LetterGroupChoice(LearningGroup model) => Model = model; +} + +public sealed class LetterGenerationIssue(string message, bool isStrong) +{ + public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; + public string Message { get; } = message; + public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; +} diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index b0e19a8..6d4b958 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -9,9 +9,56 @@ using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Students; +public static class GenderAvatarDisplay +{ + public static List Options { get; } = ["", "M – männlich", "W – weiblich", "D – divers"]; + + public static string Label(Gender? gender) => gender switch + { + Gender.M => "M", + Gender.W => "W", + Gender.D => "D", + _ => "?", + }; + + public static string Color(Gender? gender) => gender switch + { + Gender.M => "#3B82F6", + Gender.W => "#A855F7", + Gender.D => "#0D9488", + _ => "#6B7280", + }; + + public static string Tooltip(Gender? gender) => gender switch + { + Gender.M => "Geschlecht: männlich", + Gender.W => "Geschlecht: weiblich", + Gender.D => "Geschlecht: divers", + _ => "Geschlecht nicht angegeben", + }; + + public static string ToOption(Gender? gender) => gender switch + { + Gender.M => "M – männlich", + Gender.W => "W – weiblich", + Gender.D => "D – divers", + _ => "", + }; + + public static Gender? FromOption(string? option) => option switch + { + "M – männlich" => Gender.M, + "W – weiblich" => Gender.W, + "D – divers" => Gender.D, + _ => null, + }; +} + public partial class StudentListViewModel : ObservableObject { private readonly IStudentRepository _students; + private readonly IGroupRepository _groups; + private readonly IGroupMembershipRepository _memberships; public Action? OnNavigateToDetail { get; set; } [ObservableProperty] private string _searchText = ""; @@ -21,9 +68,12 @@ public partial class StudentListViewModel : ObservableObject public ObservableCollection Students { get; } = []; public string CountSummary => $"{Students.Count} Schüler gesamt"; - public StudentListViewModel(IStudentRepository students) + public StudentListViewModel(IStudentRepository students, IGroupRepository groups, + IGroupMembershipRepository memberships) { _students = students; + _groups = groups; + _memberships = memberships; LoadStudents(); } @@ -38,9 +88,21 @@ public partial class StudentListViewModel : ObservableObject { Students.Clear(); var all = _students.GetAll(ShowInactive); - var f = string.IsNullOrWhiteSpace(SearchText) ? all - : all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) - || s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + var query = SearchText.Trim(); + IEnumerable f = all; + if (query.Length > 0) + { + var matchingStudentIds = _groups.GetAll(includeInactive: true) + .Where(g => g.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + .SelectMany(g => _memberships.GetByGroup(g.Id)) + .Select(m => m.StudentId) + .ToHashSet(); + + f = all.Where(s => s.LastName.Contains(query, StringComparison.OrdinalIgnoreCase) + || s.FirstName.Contains(query, StringComparison.OrdinalIgnoreCase) + || s.FullName.Contains(query, StringComparison.OrdinalIgnoreCase) + || matchingStudentIds.Contains(s.Id)); + } foreach (var s in f) Students.Add(new StudentListItem(s)); OnPropertyChanged(nameof(CountSummary)); } @@ -63,10 +125,92 @@ public class StudentListItem public Guid Id { get; } public string FullName { get; } public string DateOfBirth { get; } + public string AvatarLabel { get; } + public string AvatarColor { get; } + public string AvatarTooltip { get; } public StudentListItem(Student s) { Id = s.Id; FullName = s.FullName; DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? ""; + AvatarLabel = GenderAvatarDisplay.Label(s.Gender); + AvatarColor = GenderAvatarDisplay.Color(s.Gender); + AvatarTooltip = GenderAvatarDisplay.Tooltip(s.Gender); + } +} + +public enum StudentManagementResult { Cancelled, Deactivated, Reactivated, Deleted } + +public class StudentReferenceItem(string label, int count) +{ + public string Label { get; } = label; + public int Count { get; } = count; + public string CountDisplay => Count.ToString(CultureInfo.InvariantCulture); +} + +public partial class ManageStudentDialogViewModel : ObservableObject +{ + private readonly IStudentRepository _students; + private readonly Student _student; + + [ObservableProperty] private string _errorMessage = ""; + + public string StudentName => _student.FullName; + public bool IsActive => _student.IsActive; + public bool IsInactive => !IsActive; + public StudentReferenceSummary References { get; } + public bool HasReferences => References.HasReferences; + public bool CanDelete => !HasReferences; + public ObservableCollection ReferenceItems { get; } = []; + public StudentManagementResult Result { get; private set; } + + public ManageStudentDialogViewModel(IStudentRepository students, Student student) + { + _students = students; + _student = student; + References = students.GetReferenceSummary(student.Id); + + AddReference("Gruppenzuordnungen", References.Memberships); + AddReference("Klausurergebnisse", References.ExamResults); + AddReference("Einzelnoten", References.Grades); + AddReference("Zeugnisnoten", References.ReportGrades); + AddReference("Mitarbeitseinträge", References.ParticipationEntries); + AddReference("Dokumentationseinträge", References.DocumentationEntries); + } + + [RelayCommand] + private void Deactivate() + { + _student.IsActive = false; + _students.Save(_student); + Result = StudentManagementResult.Deactivated; + } + + [RelayCommand] + private void Reactivate() + { + _student.IsActive = true; + _students.Save(_student); + Result = StudentManagementResult.Reactivated; + } + + [RelayCommand(CanExecute = nameof(CanDelete))] + private void DeletePermanently() + { + ErrorMessage = ""; + try + { + _students.Delete(_student.Id); + Result = StudentManagementResult.Deleted; + } + catch (InvalidOperationException ex) + { + ErrorMessage = ex.Message; + } + } + + private void AddReference(string label, int count) + { + if (count > 0) ReferenceItems.Add(new StudentReferenceItem(label, count)); } } @@ -91,9 +235,17 @@ public partial class StudentDetailViewModel : ObservableObject [ObservableProperty] private bool _isEditing; [ObservableProperty] private string _editFirstName = ""; [ObservableProperty] private string _editLastName = ""; + [ObservableProperty] private string _editGender = ""; [ObservableProperty] private ContactItem? _selectedContact; [ObservableProperty] private AttendanceBalance? _attendance; [ObservableProperty] private string _exportStatus = ""; + [ObservableProperty] private bool _isStudentActive = true; + + public string StudentStatus => IsStudentActive ? "Aktiv" : "Inaktiv"; + public List GenderOptions => GenderAvatarDisplay.Options; + public string StudentAvatarLabel => GenderAvatarDisplay.Label(Student?.Gender); + public string StudentAvatarColor => GenderAvatarDisplay.Color(Student?.Gender); + public string StudentAvatarTooltip => GenderAvatarDisplay.Tooltip(Student?.Gender); public ObservableCollection GroupMemberships { get; } = []; public ObservableCollection Documentation { get; } = []; @@ -106,6 +258,11 @@ public partial class StudentDetailViewModel : ObservableObject public Func>? OnConfirmDeleteDocumentation { get; set; } public Func? OnSaveExportFile { get; set; } public Func>? OnConductParentCall { get; set; } + public Func>? OnManageStudent { get; set; } + public Func? OnCreateLetter { get; set; } + public Action? OnReturnToStudentList { get; set; } + + partial void OnIsStudentActiveChanged(bool value) => OnPropertyChanged(nameof(StudentStatus)); public StudentDetailViewModel(IStudentRepository students, IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects, @@ -126,8 +283,13 @@ public partial class StudentDetailViewModel : ObservableObject Student = _students.GetById(id); if (Student is null) return; StudentTitle = Student.FullName; + IsStudentActive = Student.IsActive; EditFirstName = Student.FirstName; EditLastName = Student.LastName; + EditGender = GenderAvatarDisplay.ToOption(Student.Gender); + OnPropertyChanged(nameof(StudentAvatarLabel)); + OnPropertyChanged(nameof(StudentAvatarColor)); + OnPropertyChanged(nameof(StudentAvatarTooltip)); GroupMemberships.Clear(); GradeHistory.Clear(); @@ -269,18 +431,44 @@ public partial class StudentDetailViewModel : ObservableObject } [RelayCommand] private void StartEdit() => IsEditing = true; + + [RelayCommand] + private async Task CreateLetter() + { + if (Student is not null && OnCreateLetter is not null) await OnCreateLetter(); + } + + [RelayCommand] + private async Task ManageStudent() + { + if (Student is null || OnManageStudent is null) return; + var result = await OnManageStudent(Student); + if (result == StudentManagementResult.Deleted) + { + OnReturnToStudentList?.Invoke(); + return; + } + if (result is StudentManagementResult.Deactivated or StudentManagementResult.Reactivated) + LoadStudent(Student.Id); + } + [RelayCommand] private void CancelEdit() { if (Student is null) return; EditFirstName = Student.FirstName; EditLastName = Student.LastName; + EditGender = GenderAvatarDisplay.ToOption(Student.Gender); IsEditing = false; } [RelayCommand] private void SaveEdit() { if (Student is null) return; Student.FirstName = EditFirstName; Student.LastName = EditLastName; + Student.Gender = GenderAvatarDisplay.FromOption(EditGender); _students.Save(Student); StudentTitle = Student.FullName; + OnPropertyChanged(nameof(StudentAvatarLabel)); + OnPropertyChanged(nameof(StudentAvatarColor)); + OnPropertyChanged(nameof(StudentAvatarTooltip)); IsEditing = false; } @@ -382,6 +570,7 @@ public class ContactItem public Guid Id => Model.Id; public string Name { get; } public string Relation { get; } + public string LetterSalutation { get; } public string? Phone { get; } public string? Email { get; } public string Address { get; } @@ -404,6 +593,7 @@ public class ContactItem Model = c; Name = c.Name; Relation = c.Relation; + LetterSalutation = c.LetterSalutation ?? ""; Phone = c.Phone; Email = c.Email; Address = FormatAddress(c); @@ -449,7 +639,7 @@ public partial class AddStudentDialogViewModel : ObservableObject [ObservableProperty] private string _firstNameError = ""; [ObservableProperty] private string _dateOfBirthError = ""; - public List GenderOptions { get; } = ["", "M – männlich", "W – weiblich", "D – divers"]; + public List GenderOptions => GenderAvatarDisplay.Options; public List RelationPresets { get; } = ["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"]; public ObservableCollection Contacts { get; } = []; @@ -490,13 +680,7 @@ public partial class AddStudentDialogViewModel : ObservableObject FirstName = FirstName.Trim(), LastName = LastName.Trim(), DateOfBirth = dob, - Gender = SelectedGender switch - { - "M – männlich" => Gender.M, - "W – weiblich" => Gender.W, - "D – divers" => Gender.D, - _ => (Gender?)null, - }, + Gender = GenderAvatarDisplay.FromOption(SelectedGender), Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(), Contacts = Contacts.Select(c => c.ToModel()).Where(c => !string.IsNullOrWhiteSpace(c.Name)).ToList(), }; @@ -510,6 +694,7 @@ public partial class ContactEntryViewModel : ObservableObject [ObservableProperty] private string _name = ""; [ObservableProperty] private string _relation = ""; + [ObservableProperty] private string _letterSalutation = ""; [ObservableProperty] private string _phone = ""; [ObservableProperty] private string _email = ""; [ObservableProperty] private string _street = ""; @@ -524,6 +709,7 @@ public partial class ContactEntryViewModel : ObservableObject { Name = Name.Trim(), Relation = Relation.Trim(), + LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(), Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(), Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(), Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(), @@ -538,6 +724,7 @@ public partial class ContactEditDialogViewModel : ObservableObject [ObservableProperty] private string _name = ""; [ObservableProperty] private string _relation = "Elternteil"; + [ObservableProperty] private string _letterSalutation = ""; [ObservableProperty] private string _phone = ""; [ObservableProperty] private string _email = ""; [ObservableProperty] private string _street = ""; @@ -564,6 +751,7 @@ public partial class ContactEditDialogViewModel : ObservableObject if (source is null) return; Name = source.Name; Relation = source.Relation; + LetterSalutation = source.LetterSalutation ?? ""; Phone = source.Phone ?? ""; Email = source.Email ?? ""; Street = source.Street ?? ""; @@ -614,6 +802,7 @@ public partial class ContactEditDialogViewModel : ObservableObject Id = _source?.Id ?? Guid.NewGuid(), Name = Name.Trim(), Relation = Relation.Trim(), + LetterSalutation = NullIfEmpty(LetterSalutation), Phone = NullIfEmpty(Phone), Email = NullIfEmpty(Email), Street = NullIfEmpty(Street), diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml index 72cda44..6dedec4 100644 --- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml @@ -17,9 +17,10 @@