Kapitel 7 abgeschlossen.

This commit is contained in:
2026-08-16 00:29:01 +02:00
parent da8d2bb1da
commit f9e398b225
47 changed files with 2636 additions and 107 deletions
@@ -7,6 +7,7 @@ public interface IStudentRepository
Student? GetById(Guid id);
List<Student> GetAll(bool includeInactive = false);
List<Student> GetByGroup(Guid groupId);
StudentReferenceSummary GetReferenceSummary(Guid studentId);
void Save(Student student);
void Delete(Guid id);
}
+15
View File
@@ -19,6 +19,8 @@ public class Contact
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public string Relation { get; set; } = "";
/// <summary>Vollständige Anrede für Briefe, z.B. „Sehr geehrte Frau Mustermann,“.</summary>
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;
}
@@ -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<Guid> MembershipIds,
bool CopyGradingScheme,
bool ArchiveSource);
/// <summary>Erzeugt eine fachlich saubere Folgegruppe ohne Leistungs- oder Planungsdaten.</summary>
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;
}
}
}
@@ -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<string> Tags,
IReadOnlyList<TemplateValidationIssue> 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;
}
/// <summary>
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
/// Die Originalvorlage wird nie verändert.
/// </summary>
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<LetterPlaceholder> 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<LetterTemplateInfo> 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<string>();
var issues = new List<TemplateValidationIssue>();
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<string, string?> 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<ZipArchiveEntry> 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<LetterTemplateInfo> LoadIndex()
{
if (!File.Exists(_indexPath)) return [];
try
{
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private void SaveIndex(List<LetterTemplateInfo> templates)
{
var temporaryPath = _indexPath + ".tmp";
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
File.Move(temporaryPath, _indexPath, overwrite: true);
}
}
+78
View File
@@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => new GroupMembershipRepository(db).Save(membership));
Assert.Throws<InvalidOperationException>(() => new ExamRepository(db).Save(exam));
Assert.Throws<InvalidOperationException>(() => new GradeRepository(db).Save(
new Grade { GroupId = group.Id, StudentId = Guid.NewGuid(), Value = "2" }));
Assert.Throws<InvalidOperationException>(() => new UnitRepository(db).Save(
new Unit { GroupId = group.Id, Title = "Einheit" }));
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => repo.Save(archived));
archived.IsActive = true;
Assert.Throws<InvalidOperationException>(() => 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()
{
+172 -21
View File
@@ -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<Exam> GetAll() => db.Exams.FindAll().OrderBy(e => e.Date).ToList();
public List<Exam> 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<ExamResult> 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<Grade> 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<ReportGrade> 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<Unit> 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<Lesson> 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<ParticipationSession> 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<ParticipationEntry> 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<ParticipationAspect> 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<ParticipationSection> 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
@@ -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 =>
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
$"<w:body>{controls}</w:body></w:document>");
}
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);
}
}
+12 -3
View File
@@ -8,11 +8,20 @@ namespace LehrerApp.Desktop.Tests;
public class FakeStudents(List<Student> all) : IStudentRepository
{
private readonly Dictionary<Guid, StudentReferenceSummary> _references = [];
public Student? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public List<Student> GetAll(bool includeInactive = false) => all;
public List<Student> GetAll(bool includeInactive = false) =>
includeInactive ? all.ToList() : all.Where(s => s.IsActive).ToList();
public List<Student> 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<GroupMembership> all) : IGroupMembershipRepository
@@ -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)));
}
@@ -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<GroupMembership>
{
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<GroupMembership> { 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,
};
}
@@ -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);
}
}
@@ -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";
@@ -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 },
]));
}
+2
View File
@@ -140,10 +140,12 @@ public static class AppBootstrapper
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
services.AddSingleton<SchoolYearService>();
services.AddSingleton<GroupRolloverService>();
services.AddSingleton<PublicHolidayService>();
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));
@@ -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();
@@ -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<GroupRolloverStudentItem> 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";
}
}
@@ -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<Guid, int>? OnNavigateToDetail { get; set; }
public Func<Task>? OnAddGroup { get; set; }
public Func<Guid, Task>? OnEditGroup { get; set; }
public Func<Guid, Task<Guid?>>? OnRollOverGroup { get; set; }
public Func<GroupListItem, Task<bool>>? 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<StudentSummary> Students { get; } = [];
@@ -194,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject
public Func<ExamSummary, Task<bool>>? OnConfirmDeleteExam { get; set; }
public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Func<Task<bool>>? 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 ? "16" : "015")}";
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<StudentSummary>? 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
@@ -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<string> 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;
@@ -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;
@@ -64,7 +64,7 @@ public partial class MainWindowViewModel : ObservableObject
{
NavItem.Dashboard => GetDashboard(),
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
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<StudentListViewModel>();
students.SelectedStudent = null;
students.LoadStudents();
return students;
}
private TimetableViewModel GetTimetable()
{
var timetable = _services.GetRequiredService<TimetableViewModel>();
@@ -120,9 +128,12 @@ public partial class MainWindowViewModel : ObservableObject
{
ActiveNavItem = NavItem.Students;
var vm = _services.GetRequiredService<StudentDetailViewModel>();
vm.OnReturnToStudentList = NavigateToStudents;
vm.LoadStudent(studentId);
CurrentPage = vm;
}
public void NavigateToStudents() => NavigateTo(NavItem.Students);
}
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
@@ -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<string> GradingSystemOptions { get; } = ["Noten 16", "Punkte 015"];
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
// ── Word-Briefvorlagen (7.1.4 / 11.5) ───────────────────────────────────
[ObservableProperty] private string _letterTemplateStatus = "";
public ObservableCollection<LetterTemplateListItem> LetterTemplateList { get; } = [];
public IReadOnlyList<LetterPlaceholder> SupportedLetterPlaceholders =>
LetterTemplateService.SupportedPlaceholders;
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
@@ -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<LetterTemplateIssueItem> Issues { get; }
public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation)
{
Model = model;
Validation = validation;
Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i)));
}
}
public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue)
{
public string Icon => issue.Severity switch
{
TemplateIssueSeverity.Error => "⛔",
TemplateIssueSeverity.StrongWarning => "⚠",
_ => "ⓘ",
};
public string Message => issue.Message;
public string Color => issue.Severity switch
{
TemplateIssueSeverity.Error => "#DC2626",
TemplateIssueSeverity.StrongWarning => "#D97706",
_ => "#6B7280",
};
}
public class ExpiredDocumentItem(Documentation d, string studentName)
{
public Guid Id { get; } = d.Id;
@@ -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<LetterTemplateChoice> Templates { get; } = [];
public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
public ObservableCollection<LetterGenerationIssue> 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<string, string?> 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<string, string?>
{
["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";
}
@@ -9,9 +9,56 @@ using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students;
public static class GenderAvatarDisplay
{
public static List<string> 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<Guid>? OnNavigateToDetail { get; set; }
[ObservableProperty] private string _searchText = "";
@@ -21,9 +68,12 @@ public partial class StudentListViewModel : ObservableObject
public ObservableCollection<StudentListItem> 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<Student> 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<StudentReferenceItem> 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<string> 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<GroupMembershipEntry> GroupMemberships { get; } = [];
public ObservableCollection<DocumentationItem> Documentation { get; } = [];
@@ -106,6 +258,11 @@ public partial class StudentDetailViewModel : ObservableObject
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
public Func<string, Task>? OnSaveExportFile { get; set; }
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
public Func<Student, Task<StudentManagementResult>>? OnManageStudent { get; set; }
public Func<Task>? 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<string> GenderOptions { get; } = ["", "M männlich", "W weiblich", "D divers"];
public List<string> GenderOptions => GenderAvatarDisplay.Options;
public List<string> RelationPresets { get; } = ["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"];
public ObservableCollection<ContactEntryViewModel> 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),
@@ -17,9 +17,10 @@
<Button Content="Sortierung: Gesamt" Command="{Binding SortByTotalCommand}"/>
<Button Content="Noten verwalten" Command="{Binding ManageStudentGradesCommand}" Margin="12,0,0,0"
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Content=" Sammelnote" Command="{Binding CollectiveGradeCommand}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}"/>
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"
IsEnabled="{Binding !IsReadOnly}"/>
<Button Content=" Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
<DataGrid Grid.Row="1"
@@ -7,7 +7,7 @@
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
x:DataType="vm:GroupDetailViewModel">
<Grid RowDefinitions="Auto,*">
<Grid RowDefinitions="Auto,Auto,*">
<!-- Header -->
<Border Grid.Row="0" Padding="20,16"
@@ -22,18 +22,32 @@
</TextBlock>
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
VerticalAlignment="Center"/>
<Button Content=" Schüler" Command="{Binding AddStudentCommand}"/>
<Button Content=" Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
IsVisible="{Binding SelectedStudent.HasExitDate}"/>
<Button Content=" Klausur" Command="{Binding AddExamCommand}"/>
<Button Content=" Klausur" Command="{Binding AddExamCommand}" IsEnabled="{Binding IsEditable}"/>
</StackPanel>
</Grid>
</Border>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<Border Grid.Row="1" Background="#FFF3CD" BorderBrush="#D97706" BorderThickness="0,0,0,1"
Padding="20,10" IsVisible="{Binding IsReadOnly}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="Archivierte Lerngruppe · schreibgeschützt" FontWeight="SemiBold"
Foreground="#92400E"/>
<TextBlock Text="Alle historischen Daten bleiben sichtbar. Für Korrekturen muss die Gruppe ausdrücklich wieder aktiviert werden."
FontSize="12" Foreground="#92400E" TextWrapping="Wrap"/>
</StackPanel>
<Button Grid.Column="1" Content="Gruppe wieder aktivieren"
Command="{Binding ReactivateGroupCommand}" Margin="16,0,0,0"/>
</Grid>
</Border>
<TabbedPage Grid.Row="2" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<!-- Tab: Übersicht -->
<ContentPage Header="Übersicht">
@@ -57,6 +71,18 @@
CanUserResizeColumns="True"
Margin="0">
<DataGrid.Columns>
<DataGridTemplateColumn Header="" Width="48">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:StudentSummary">
<Border Width="30" Height="30" CornerRadius="15" Background="{Binding AvatarColor}"
HorizontalAlignment="Center" VerticalAlignment="Center"
ToolTip.Tip="{Binding AvatarTooltip}">
<TextBlock Text="{Binding AvatarLabel}" Foreground="White" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="190"/>
<DataGridTextColumn Header="Status" Binding="{Binding MembershipStatus}" Width="170"/>
@@ -66,6 +92,7 @@
<DataTemplate x:DataType="vm:StudentSummary">
<ComboBox ItemsSource="{x:Static vm:StudentSummary.NiveauOptions}"
SelectedItem="{Binding NiveauName}"
IsEnabled="{Binding CanEdit}"
HorizontalAlignment="Stretch" Margin="2"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@@ -83,7 +110,8 @@
<ContentPage Header="Klausuren">
<Grid RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8"
IsVisible="{Binding SelectedExam, Converter={x:Static ObjectConverters.IsNotNull}}">
IsVisible="{Binding SelectedExam, Converter={x:Static ObjectConverters.IsNotNull}}"
IsEnabled="{Binding IsEditable}">
<Button Content="Punkte eingeben" Command="{Binding GradeExamCommand}"/>
<Button Content="Auswertung" Command="{Binding EvaluateExamCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditExamCommand}"/>
@@ -3,6 +3,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -24,9 +25,25 @@ public partial class GroupDetailView : UserControl
vm.OnConfirmDeleteExam = ShowDeleteExamDialog;
vm.OnGradeExam = ShowGradeExamDialog;
vm.OnEvaluateExam = ShowEvaluateExamDialog;
vm.OnConfirmReactivate = ShowReactivateConfirmDialog;
}
}
private async Task<bool> ShowReactivateConfirmDialog()
{
var dialog = new ConfirmDialog
{
DataContext = new ConfirmDialogInfo
{
Title = "Archivierte Gruppe wieder aktivieren?",
Message = "Nach der Wiederaktivierung können historische Daten dieser Gruppe wieder verändert werden.",
ConfirmText = "Wieder aktivieren",
},
};
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<bool> ShowAddStudentDialog()
{
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
@@ -91,6 +91,8 @@
<MenuFlyout>
<MenuItem Header="Details bearbeiten"
Command="{Binding EditGroupCommand}"/>
<MenuItem Header="Ins nächste Schuljahr übernehmen …"
Command="{Binding RollOverGroupCommand}"/>
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}"
Command="{Binding ToggleArchiveCommand}"/>
<Separator/>
@@ -15,6 +15,7 @@ public partial class GroupListView : UserControl
{
vm.OnAddGroup = ShowAddGroupDialog;
vm.OnEditGroup = ShowEditGroupDialog;
vm.OnRollOverGroup = ShowGroupRolloverDialog;
vm.OnConfirmDelete = ShowDeleteGroupDialog;
}
}
@@ -42,6 +43,23 @@ public partial class GroupListView : UserControl
await dialog.ShowDialog<bool>(owner);
}
private async Task<Guid?> ShowGroupRolloverDialog(Guid groupId)
{
var group = App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IGroupRepository>()
.GetById(groupId);
var owner = TopLevel.GetTopLevel(this) as Window;
if (group is null || owner is null) return null;
var dialogVm = new GroupRolloverDialogViewModel(group,
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IStudentRepository>(),
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IGroupMembershipRepository>(),
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IGradingSchemeRepository>(),
App.Services.GetRequiredService<LehrerApp.Core.Services.SchoolYearService>(),
App.Services.GetRequiredService<LehrerApp.Core.Services.GroupRolloverService>());
var dialog = new GroupRolloverDialog { DataContext = dialogVm };
return await dialog.ShowDialog<Guid?>(owner);
}
private async Task<bool> ShowDeleteGroupDialog(GroupListItem group)
{
var dialog = new DeleteGroupDialog { DataContext = group.DisplayName };
@@ -0,0 +1,75 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GroupRolloverDialog"
x:DataType="vm:GroupRolloverDialogViewModel"
Title="Ins nächste Schuljahr übernehmen"
Width="650" Height="720" MinWidth="560" MinHeight="600"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24,20">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="Ins nächste Schuljahr übernehmen" Classes="dialogtitle"/>
<TextBlock Text="{Binding SourceSummary}" FontSize="12" Opacity="0.65"/>
<TextBlock Text="Es werden nur Gruppeneinstellungen und ausgewählte Mitgliedschaften kopiert. Noten, Klausuren, Mitarbeit, Planung und Stundenplan bleiben im alten Schuljahr."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="*,12,130,12,100">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Neuer Gruppenname *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Name}"/>
<TextBlock Text="{Binding NameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding NameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Zielschuljahr *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding TargetSchoolYear}" PlaceholderText="2027/28"/>
<TextBlock Text="{Binding SchoolYearError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolYearError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Text="Klassenstufe" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding GradeLevel}" Minimum="1" Maximum="13" FormatString="0"/>
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Schülerauswahl" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding SelectedCountText}" FontSize="12" Opacity="0.6"/>
</Grid>
</StackPanel>
<Grid Grid.Row="1" RowDefinitions="*,Auto" Margin="0,10,0,0">
<ListBox Grid.Row="0" ItemsSource="{Binding Students}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupRolloverStudentItem">
<Grid ColumnDefinitions="Auto,*,70" Margin="4,5">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" IsEnabled="{Binding CanSelect}"
VerticalAlignment="Center" Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding FullName}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding MembershipStatus}" FontSize="11" Opacity="0.55"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding NiveauText}" FontSize="12" Opacity="0.65"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Text="{Binding SelectionError}" Foreground="Red" FontSize="11" Margin="0,5,0,0"
IsVisible="{Binding SelectionError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<StackPanel Grid.Row="2" Spacing="10" Margin="0,14,0,0">
<CheckBox Content="Gruppenspezifisches Notenschema übernehmen"
IsChecked="{Binding CopyGradingScheme}" IsVisible="{Binding HasGradingScheme}"/>
<CheckBox Content="Alte Gruppe nach erfolgreicher Übernahme archivieren"
IsChecked="{Binding ArchiveSource}"/>
<TextBlock Text="{Binding GeneralError}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding GeneralError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,10,*">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Gruppe übernehmen" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupRolloverDialog : Window
{
public GroupRolloverDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not GroupRolloverDialogViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(vm.Result.Id);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -13,19 +13,19 @@
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<Button Grid.Row="0" Content=" Sitzung" Command="{Binding AddSessionCommand}"
HorizontalAlignment="Stretch" Margin="10,10,10,6"/>
HorizontalAlignment="Stretch" Margin="10,10,10,6" IsEnabled="{Binding !IsReadOnly}"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="10,0,10,6">
<Button Content="Schnell Mitarbeit" Command="{Binding QuickInputCommand}"
HorizontalAlignment="Stretch"/>
<Button Content="Anw./HA" Command="{Binding StatusQuickInputCommand}"/>
HorizontalAlignment="Stretch" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Anw./HA" Command="{Binding StatusQuickInputCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
<Button Grid.Row="2" Content="Ø Mitarbeitsnote" Command="{Binding ComputeGradeCommand}"
HorizontalAlignment="Stretch" Margin="10,0,10,6"/>
HorizontalAlignment="Stretch" Margin="10,0,10,6" IsEnabled="{Binding !IsReadOnly}"/>
<Button Grid.Row="3" Content="Mitarbeits-Assistent" Command="{Binding OpenWizardCommand}"
HorizontalAlignment="Stretch" Margin="10,0,10,6"/>
HorizontalAlignment="Stretch" Margin="10,0,10,6" IsEnabled="{Binding !IsReadOnly}"/>
<ListBox Grid.Row="4"
ItemsSource="{Binding Sessions}"
@@ -68,7 +68,7 @@
BorderThickness="0,0,0,1"
Padding="12,8">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" MaxHeight="200">
<ItemsControl ItemsSource="{Binding CompetencyTagGroups}">
<ItemsControl ItemsSource="{Binding CompetencyTagGroups}" IsEnabled="{Binding !IsReadOnly}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:CompetencyTagGroup">
<StackPanel Spacing="4" Margin="0,0,0,10">
@@ -58,7 +58,7 @@ public partial class ParticipationTabView : UserControl
{
Header = $"{aspect.Label} [{AspectShortcut(i)}]",
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
CellTemplate = BuildCellTemplate(idx, forCompetency: false),
CellTemplate = BuildCellTemplate(idx, forCompetency: false, _vm.IsReadOnly),
});
}
@@ -66,13 +66,13 @@ public partial class ParticipationTabView : UserControl
{
Header = "HA",
Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
CellTemplate = BuildHomeworkCellTemplate(),
CellTemplate = BuildHomeworkCellTemplate(_vm.IsReadOnly),
});
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Anwesenheit",
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
CellTemplate = BuildAttendanceCellTemplate(),
CellTemplate = BuildAttendanceCellTemplate(_vm.IsReadOnly),
});
// Kompetenz-Spalten (opt-in)
@@ -85,13 +85,13 @@ public partial class ParticipationTabView : UserControl
{
Header = code,
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
CellTemplate = BuildCellTemplate(idx, forCompetency: true),
CellTemplate = BuildCellTemplate(idx, forCompetency: true, _vm.IsReadOnly),
});
}
}
}
private static IDataTemplate BuildCellTemplate(int cellIndex, bool forCompetency)
private static IDataTemplate BuildCellTemplate(int cellIndex, bool forCompetency, bool isReadOnly)
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
@@ -119,6 +119,7 @@ public partial class ParticipationTabView : UserControl
Padding = new Avalonia.Thickness(5, 1),
FontSize = 11,
Opacity = cell.Value == val ? 1.0 : 0.3,
IsEnabled = !isReadOnly,
};
var capturedVal = val;
btn.Click += (_, _) => cell.SetValue(capturedVal);
@@ -134,7 +135,7 @@ public partial class ParticipationTabView : UserControl
});
}
private static IDataTemplate BuildHomeworkCellTemplate()
private static IDataTemplate BuildHomeworkCellTemplate(bool isReadOnly)
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
@@ -147,6 +148,7 @@ public partial class ParticipationTabView : UserControl
Height = 26,
Padding = new Avalonia.Thickness(3, 1),
Command = row.ToggleHomeworkCommand,
IsEnabled = !isReadOnly,
};
void RefreshHomework()
{
@@ -180,7 +182,7 @@ public partial class ParticipationTabView : UserControl
});
}
private static IDataTemplate BuildAttendanceCellTemplate()
private static IDataTemplate BuildAttendanceCellTemplate(bool isReadOnly)
{
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
{
@@ -193,6 +195,7 @@ public partial class ParticipationTabView : UserControl
Height = 26,
Padding = new Avalonia.Thickness(3, 1),
Command = row.CycleAttendanceCommand,
IsEnabled = !isReadOnly,
};
void RefreshAttendance()
{
@@ -11,10 +11,10 @@
<TextBlock Grid.Column="0" Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content=" Einheit" Command="{Binding AddUnitCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}"/>
<Button Content=" Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
</Grid>
@@ -62,15 +62,16 @@
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content=" Stunde" Command="{Binding AddLessonCommand}"/>
<Button Content=" Stunde" Command="{Binding AddLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Serie erzeugen" Command="{Binding GenerateLessonSeriesCommand}"
IsEnabled="{Binding !IsReadOnly}"
ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/>
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}"/>
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
</StackPanel>
</Grid>
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
xmlns:services="clr-namespace:LehrerApp.Core.Services;assembly=LehrerApp.Core"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Settings.SettingsView"
x:DataType="vm:SettingsViewModel">
@@ -270,6 +271,86 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
<ContentPage Header="Briefvorlagen">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="16" MaxWidth="760">
<TextBlock Text="Word-Vorlagen bleiben vollständig in Word gestaltet. Die App befüllt Inhaltssteuerelemente anhand ihres Tags und prüft die Vorlage bereits beim Import."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<Button Grid.Column="0" Content=" DOCX-Vorlage importieren" Click="OnImportLetterTemplateClick"/>
<TextBlock Grid.Column="1" Text="{Binding LetterTemplateStatus}" FontSize="12"
VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding LetterTemplateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<TextBlock Text="Noch keine Briefvorlage importiert." Classes="emptyhint"
IsVisible="{Binding !LetterTemplateList.Count}"/>
<ItemsControl ItemsSource="{Binding LetterTemplateList}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="7" Padding="14,12" Margin="0,0,0,9">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
<TextBlock FontSize="11" Opacity="0.55">
<Run Text="{Binding OriginalFileName}"/><Run Text=" · "/>
<Run Text="{Binding ValidationSummary}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Öffnen" FontSize="12" Padding="10,4"
Margin="8,0,0,0" Tag="{Binding}" Click="OnOpenLetterTemplateClick"/>
<Button Grid.Column="2" Content="Neu prüfen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).ValidateLetterTemplateCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="3" Content="Löschen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteLetterTemplateCommand}"
CommandParameter="{Binding}"/>
</Grid>
<ItemsControl ItemsSource="{Binding Issues}" IsVisible="{Binding HasIssues}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LetterTemplateIssueItem">
<Grid ColumnDefinitions="24,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" Foreground="{Binding Color}"
FontSize="12" TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="✓ Vorlage ohne Auffälligkeiten" Foreground="SeaGreen" FontSize="12"
IsVisible="{Binding HasNoIssues}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Separator/>
<TextBlock Text="Unterstützte Tags" FontSize="15" FontWeight="SemiBold"/>
<TextBlock Text="In Word: Entwicklertools → Rich-Text- oder Nur-Text-Inhaltssteuerelement → Eigenschaften → Tag. Der Titel ist frei wählbar; ausgewertet wird der Tag."
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding SupportedLetterPlaceholders}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="services:LetterPlaceholder">
<Grid ColumnDefinitions="190,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Tag}" FontFamily="Monospace" FontSize="12"/>
<TextBlock Grid.Column="1" Text="{Binding Description}" FontSize="12" Opacity="0.7"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Notenschema -->
<ContentPage Header="Notenschema">
<ContentPage.Resources>
@@ -91,4 +91,27 @@ public partial class SettingsView : UserControl
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
private async void OnImportLetterTemplateClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not SettingsViewModel vm) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Word-Briefvorlage importieren",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
});
if (files.Count > 0) vm.ImportLetterTemplate(files[0].Path.LocalPath);
}
private void OnOpenLetterTemplateClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: LetterTemplateListItem item }) return;
var service = App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>();
var path = service.GetTemplatePath(item.Model);
if (!File.Exists(path)) return;
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
}
@@ -72,6 +72,9 @@
<Button Grid.Column="4" Content="×" Padding="6,2"
Command="{Binding RemoveCommand}" FontSize="14"/>
</Grid>
<TextBox Text="{Binding LetterSalutation}"
PlaceholderText="Briefanrede, z.B. Sehr geehrte Frau Mustermann,"
FontSize="12"/>
<!-- Zeile 2: Telefon + E-Mail -->
<Grid ColumnDefinitions="*,10,*">
<TextBox Grid.Column="0" Text="{Binding Phone}"
@@ -28,6 +28,14 @@
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Briefanrede" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterSalutation}"
PlaceholderText="z.B. Sehr geehrte Frau Mustermann,"/>
<TextBlock Text="Wird unverändert in Word-Vorlagen für Letter.Salutation eingesetzt."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel>
<Grid ColumnDefinitions="*,10,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Telefon" FontSize="12" Opacity="0.7"/>
@@ -0,0 +1,75 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.CreateLetterDialog"
x:DataType="vm:CreateLetterDialogViewModel"
Title="Word-Brief erstellen" Width="580" Height="650"
MinWidth="500" MinHeight="540" CanResize="True"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24,20">
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="15">
<TextBlock Text="Word-Brief erstellen" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.65" TextWrapping="Wrap"
Text="Die Vorlage wird vor der Erzeugung erneut geprüft. Das Original bleibt unverändert; gespeichert wird eine frei bearbeitbare DOCX-Kopie."/>
<StackPanel Spacing="4">
<TextBlock Text="Schüler" FontSize="12" Opacity="0.7"/>
<TextBlock Text="{Binding StudentName}" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Vorlage *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Templates}" SelectedItem="{Binding SelectedTemplate}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Noch keine Vorlage. Bitte unter Einstellungen → Briefvorlagen importieren."
Classes="emptyhint" IsVisible="{Binding HasNoTemplates}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Kontakt *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}"
DisplayMemberBinding="{Binding Display}" HorizontalAlignment="Stretch"/>
<TextBlock Text="Kein aktueller Kontakt vorhanden." Classes="emptyhint"
IsVisible="{Binding HasNoContacts}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
DisplayMemberBinding="{Binding Display}" PlaceholderText="Optional"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Briefdatum *" FontSize="12" Opacity="0.7"/>
<CalendarDatePicker SelectedDate="{Binding LetterDate}" HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}">
<StackPanel Spacing="5">
<TextBlock Text="Vor dem Erzeugen korrigieren" FontWeight="SemiBold" Foreground="#D97706"/>
<ItemsControl ItemsSource="{Binding Issues}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LetterGenerationIssue">
<Grid ColumnDefinitions="24,*" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Icon}" Foreground="{Binding Color}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" Foreground="{Binding Color}"
FontSize="12" TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Text="{Binding GenerationError}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding GenerationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,10,*" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="DOCX speichern …" HorizontalAlignment="Stretch"
IsEnabled="{Binding CanGenerate}" Click="OnGenerate"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,27 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class CreateLetterDialog : Window
{
public CreateLetterDialog() => InitializeComponent();
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (DataContext is not CreateLetterDialogViewModel vm) return;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Word-Brief speichern",
SuggestedFileName = vm.SuggestedFileName,
DefaultExtension = "docx",
FileTypeChoices = [new FilePickerFileType("Word-Dokumente") { Patterns = ["*.docx"] }],
});
if (file is null || !vm.Generate(file.Path.LocalPath)) return;
Close(file.Path.LocalPath);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -0,0 +1,59 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.ManageStudentDialog"
x:DataType="vm:ManageStudentDialogViewModel"
Title="Schüler verwalten"
Width="500" Height="500"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Schüler verwalten" Classes="dialogtitle"/>
<TextBlock Text="{Binding StudentName}" FontSize="15" FontWeight="SemiBold"/>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="14">
<StackPanel Spacing="8">
<TextBlock Text="Deaktivieren blendet den Schüler aus den normalen Listen aus. Alle historischen Daten bleiben erhalten."
TextWrapping="Wrap" FontSize="12"/>
<TextBlock Text="Der Schüler ist bereits deaktiviert und kann wieder aktiviert werden."
TextWrapping="Wrap" FontSize="12" IsVisible="{Binding IsInactive}"/>
</StackPanel>
</Border>
<StackPanel Spacing="8" IsVisible="{Binding HasReferences}">
<TextBlock Text="Verknüpfte Daten" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding ReferenceItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentReferenceItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Label}"/>
<TextBlock Grid.Column="1" Text="{Binding CountDisplay}" FontWeight="SemiBold"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Eine endgültige Löschung ist deshalb gesperrt. Verwende stattdessen Deaktivieren."
Foreground="#D97706" TextWrapping="Wrap" FontSize="12"/>
</StackPanel>
<Border IsVisible="{Binding CanDelete}" BorderBrush="#C62828" BorderThickness="1"
CornerRadius="6" Padding="12">
<TextBlock Text="Es bestehen keine Verknüpfungen. Der Schüler kann endgültig gelöscht werden. Stammdaten und Kontakte gehen dabei unwiderruflich verloren."
Foreground="#C62828" TextWrapping="Wrap" FontSize="12"/>
</Border>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="8" Margin="0,18,0,0">
<Button Grid.Column="0" Content="Deaktivieren" Click="OnDeactivate" IsVisible="{Binding IsActive}"/>
<Button Grid.Column="0" Content="Wieder aktivieren" Click="OnReactivate" IsVisible="{Binding IsInactive}"/>
<Button Grid.Column="1" Content="Endgültig löschen" Click="OnDeletePermanently"
IsEnabled="{Binding CanDelete}"/>
<Button Grid.Column="3" Content="Schließen" Click="OnCancel"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,33 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class ManageStudentDialog : Window
{
public ManageStudentDialog() => InitializeComponent();
private void OnDeactivate(object? sender, RoutedEventArgs e)
{
if (DataContext is not ManageStudentDialogViewModel vm) return;
vm.DeactivateCommand.Execute(null);
Close(vm.Result);
}
private void OnReactivate(object? sender, RoutedEventArgs e)
{
if (DataContext is not ManageStudentDialogViewModel vm) return;
vm.ReactivateCommand.Execute(null);
Close(vm.Result);
}
private void OnDeletePermanently(object? sender, RoutedEventArgs e)
{
if (DataContext is not ManageStudentDialogViewModel vm) return;
vm.DeletePermanentlyCommand.Execute(null);
if (vm.Result == StudentManagementResult.Deleted) Close(vm.Result);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(StudentManagementResult.Cancelled);
}
@@ -11,16 +11,29 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto">
<shared:PageHeader Grid.Column="0" IsVisible="{Binding !IsEditing}" Title="{Binding StudentTitle}"/>
<Border Grid.Column="0" Width="38" Height="38" CornerRadius="19"
Background="{Binding StudentAvatarColor}" HorizontalAlignment="Left"
ToolTip.Tip="{Binding StudentAvatarTooltip}" IsVisible="{Binding !IsEditing}">
<TextBlock Text="{Binding StudentAvatarLabel}" Foreground="White" FontWeight="Bold" FontSize="15"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<shared:PageHeader Grid.Column="0" Margin="50,0,0,0" IsVisible="{Binding !IsEditing}" Title="{Binding StudentTitle}"/>
<StackPanel Grid.Column="0" Spacing="6" IsVisible="{Binding IsEditing}">
<Grid ColumnDefinitions="*,8,*">
<Grid ColumnDefinitions="*,8,*,8,170">
<TextBox Grid.Column="0" Text="{Binding EditFirstName}" PlaceholderText="Vorname"/>
<TextBox Grid.Column="2" Text="{Binding EditLastName}" PlaceholderText="Nachname"/>
<ComboBox Grid.Column="4" ItemsSource="{Binding GenderOptions}"
SelectedItem="{Binding EditGender}" PlaceholderText="Geschlecht"/>
</Grid>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<TextBlock Text="{Binding StudentStatus}" VerticalAlignment="Center" Opacity="0.65"/>
<Button Content="Word-Brief" Command="{Binding CreateLetterCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Schüler verwalten" Command="{Binding ManageStudentCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Speichern" Command="{Binding SaveEditCommand}"
IsVisible="{Binding IsEditing}"/>
<Button Content="Abbrechen" Command="{Binding CancelEditCommand}"
@@ -88,6 +101,9 @@
CornerRadius="4" Padding="6,2" HorizontalAlignment="Left">
<TextBlock Text="{Binding Relation}" FontSize="11" Opacity="0.7"/>
</Border>
<TextBlock Text="{Binding LetterSalutation}" FontSize="11" Opacity="0.6"
TextWrapping="Wrap"
IsVisible="{Binding LetterSalutation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="1" Grid.Row="0" Orientation="Horizontal" Spacing="8">
@@ -23,6 +23,8 @@ public partial class StudentDetailView : UserControl
vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
vm.OnSaveExportFile = SaveExportFile;
vm.OnConductParentCall = ShowParentCallSessionDialog;
vm.OnManageStudent = ShowManageStudentDialog;
vm.OnCreateLetter = ShowCreateLetterDialog;
}
private async Task<Contact?> ShowContactDialog(Contact? contact)
@@ -36,6 +38,32 @@ public partial class StudentDetailView : UserControl
return saved ? vm.Result : null;
}
private async Task<StudentManagementResult> ShowManageStudentDialog(Student student)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return StudentManagementResult.Cancelled;
var vm = new ManageStudentDialogViewModel(
App.Services.GetRequiredService<IStudentRepository>(), student);
var dialog = new ManageStudentDialog { DataContext = vm };
return await dialog.ShowDialog<StudentManagementResult>(owner);
}
private async Task ShowCreateLetterDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not StudentDetailViewModel { Student: { } student }) return;
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<LehrerApp.Core.Services.LetterTemplateService>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>());
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
private void ShowAddressViewer(ContactItem contact)
{
if (!contact.HasAddress) return;
@@ -19,13 +19,25 @@
</Border>
<DockPanel Grid.Row="1">
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
PlaceholderText="Name suchen…" Margin="16,10,16,4"/>
PlaceholderText="Name oder Lerngruppe suchen…" Margin="16,10,16,4"/>
<DataGrid ItemsSource="{Binding Students}"
SelectedItem="{Binding SelectedStudent}"
AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False" Margin="16,4">
<DataGrid.Columns>
<DataGridTemplateColumn Header="" Width="48">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:StudentListItem">
<Border Width="30" Height="30" CornerRadius="15" Background="{Binding AvatarColor}"
HorizontalAlignment="Center" VerticalAlignment="Center"
ToolTip.Tip="{Binding AvatarTooltip}">
<TextBlock Text="{Binding AvatarLabel}" Foreground="White" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Geburtsdatum" Binding="{Binding DateOfBirth}" Width="130"/>
</DataGrid.Columns>
@@ -0,0 +1,110 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class GroupRolloverServiceTests
{
[Fact]
public void RollOver_KopiertStammdatenUndErzeugtNeueGanzjaehrigeMitgliedschaften()
{
var source = new LearningGroup
{
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
Type = GroupType.Class, SubjectId = Guid.NewGuid(), GradingSystem = GradingSystem.Grades1To6,
HoursPerWeek = 4, IsOwnClass = true, IsDifferentiated = true,
};
var oldMembership = new GroupMembership
{
StudentId = Guid.NewGuid(), GroupId = source.Id, Period = MembershipPeriod.H2Only,
JoinedAt = new DateOnly(2026, 2, 1), LeftAt = new DateOnly(2026, 7, 31), Niveau = Niveau.E,
};
var groups = new MemoryGroups([source]);
var memberships = new MemoryMemberships([oldMembership]);
var schemes = new MemorySchemes();
schemes.Save(new GradingScheme
{
GroupId = source.Id, ExamsPercent = 40, ParticipationPercent = 50, OtherPercent = 10,
});
var service = new GroupRolloverService(groups, memberships, schemes, new SchoolYearService());
var target = service.RollOver(source, new GroupRolloverRequest(
"9a", "2026/27", 9, [oldMembership.Id], true, true));
Assert.Equal("9a", target.Name);
Assert.Equal(source.SubjectId, target.SubjectId);
Assert.Equal(source.HoursPerWeek, target.HoursPerWeek);
Assert.True(target.IsOwnClass);
Assert.True(target.IsDifferentiated);
Assert.False(source.IsActive);
var copied = Assert.Single(memberships.GetByGroup(target.Id));
Assert.NotEqual(oldMembership.Id, copied.Id);
Assert.Equal(MembershipPeriod.FullYear, copied.Period);
Assert.Equal(new DateOnly(2026, 8, 1), copied.JoinedAt);
Assert.Null(copied.LeftAt);
Assert.Equal(Niveau.E, copied.Niveau);
var scheme = schemes.GetByGroup(target.Id);
Assert.NotNull(scheme);
Assert.Equal(40, scheme.ExamsPercent);
Assert.NotEqual(schemes.GetByGroup(source.Id)!.Id, scheme.Id);
}
[Fact]
public void RollOver_GleichnamigeGruppeImZieljahr_WirdAbgelehnt()
{
var subjectId = Guid.NewGuid();
var source = new LearningGroup
{
Name = "8a", SchoolYear = "2025/26", GradeLevel = 8,
Type = GroupType.Class, SubjectId = subjectId,
};
var existing = new LearningGroup
{
Name = "9a", SchoolYear = "2026/27", GradeLevel = 9,
Type = GroupType.Class, SubjectId = subjectId,
};
var membership = new GroupMembership { StudentId = Guid.NewGuid(), GroupId = source.Id };
var service = new GroupRolloverService(
new MemoryGroups([source, existing]), new MemoryMemberships([membership]),
new MemorySchemes(), new SchoolYearService());
var error = Assert.Throws<InvalidOperationException>(() => service.RollOver(source,
new GroupRolloverRequest("9a", "2026/27", 9, [membership.Id], false, false)));
Assert.Contains("bereits", error.Message);
}
private sealed class MemoryGroups(List<LearningGroup> items) : IGroupRepository
{
public LearningGroup? GetById(Guid id) => items.FirstOrDefault(g => g.Id == id);
public List<LearningGroup> GetAll(bool includeInactive = false) =>
items.Where(g => includeInactive || g.IsActive).ToList();
public List<LearningGroup> GetBySchoolYear(string schoolYear, bool includeInactive = false) =>
items.Where(g => g.SchoolYear == schoolYear && (includeInactive || g.IsActive)).ToList();
public void Save(LearningGroup group) { items.RemoveAll(g => g.Id == group.Id); items.Add(group); }
public void Delete(Guid id) => items.RemoveAll(g => g.Id == id);
}
private sealed class MemoryMemberships(List<GroupMembership> items) : IGroupMembershipRepository
{
public List<GroupMembership> GetByStudent(Guid studentId) => items.Where(m => m.StudentId == studentId).ToList();
public List<GroupMembership> GetByGroup(Guid groupId) => items.Where(m => m.GroupId == groupId).ToList();
public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) =>
items.FirstOrDefault(m => m.StudentId == studentId && m.GroupId == groupId);
public void Save(GroupMembership membership) { items.RemoveAll(m => m.Id == membership.Id); items.Add(membership); }
public void Delete(Guid id) => items.RemoveAll(m => m.Id == id);
}
private sealed class MemorySchemes : IGradingSchemeRepository
{
private readonly List<GradingScheme> _items = [];
public GradingScheme? GetByGroup(Guid groupId) => _items.FirstOrDefault(s => s.GroupId == groupId);
public GradingScheme? GetDefaultForType(GroupType type) => _items.FirstOrDefault(s => s.GroupType == type);
public void Save(GradingScheme scheme) { _items.RemoveAll(s => s.Id == scheme.Id); _items.Add(scheme); }
public void Delete(Guid id) => _items.RemoveAll(s => s.Id == id);
}
}
@@ -0,0 +1,126 @@
using System.IO.Compression;
using System.Xml.Linq;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class LetterTemplateServiceTests : IDisposable
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-tests-{Guid.NewGuid():N}");
public LetterTemplateServiceTests() => Directory.CreateDirectory(_directory);
[Fact]
public void Validate_OhneInhaltssteuerelement_Warnt()
{
var path = CreateDocx("<w:p><w:r><w:t>Brief</w:t></w:r></w:p>");
var result = new LetterTemplateService(_directory).Validate(path);
Assert.Contains(result.Issues, i => i.Severity == TemplateIssueSeverity.Warning
&& i.Message.Contains("keine Inhaltssteuerelemente"));
}
[Fact]
public void Validate_UnbekannterTag_Warnt()
{
var path = CreateDocx(Control("Something.EntirelyUnknown"));
var result = new LetterTemplateService(_directory).Validate(path);
Assert.Contains(result.Issues, i => i.Tag == "Something.EntirelyUnknown"
&& i.Severity == TemplateIssueSeverity.Warning);
}
[Fact]
public void Validate_WahrscheinlicherTippfehler_WarntDeutlichMitVorschlag()
{
var path = CreateDocx(Control("Contact.Adress"));
var result = new LetterTemplateService(_directory).Validate(path);
var issue = Assert.Single(result.Issues);
Assert.Equal(TemplateIssueSeverity.StrongWarning, issue.Severity);
Assert.Equal("Contact.Address", issue.SuggestedTag);
Assert.Contains("Meinten Sie", issue.Message);
}
[Fact]
public void Validate_FindetTagsInKopfzeile()
{
var path = CreateDocx(Control("Student.FirstName"), Control("CurrentDate"));
var result = new LetterTemplateService(_directory).Validate(path);
Assert.Contains("Student.FirstName", result.Tags);
Assert.Contains("CurrentDate", result.Tags);
Assert.Empty(result.Issues);
}
[Fact]
public void Generate_BefuelltTextUndMehrzeiligeAdresse_OhneVorlageZuVeraendern()
{
var path = CreateDocx(Control("Student.FirstName") + Control("Contact.Address"));
var original = File.ReadAllBytes(path);
var output = Path.Combine(_directory, "filled.docx");
var service = new LetterTemplateService(_directory);
service.Generate(path, output, new Dictionary<string, string?>
{
["Student.FirstName"] = "Mara",
["Contact.Address"] = "Hauptstraße 1\n12345 Musterstadt",
});
Assert.Equal(original, File.ReadAllBytes(path));
using var archive = ZipFile.OpenRead(output);
var entry = archive.GetEntry("word/document.xml")!;
using var reader = new StreamReader(entry.Open());
var xml = XDocument.Parse(reader.ReadToEnd());
Assert.Contains("Mara", xml.Descendants(W + "t").Select(t => t.Value));
Assert.Single(xml.Descendants(W + "br"));
Assert.Contains("12345 Musterstadt", xml.Descendants(W + "t").Select(t => t.Value));
}
[Fact]
public void Import_KopiertVorlageUndLoeschenEntferntSie()
{
var path = CreateDocx(Control("CurrentDate"));
var service = new LetterTemplateService(Path.Combine(_directory, "appdata"));
var imported = service.Import(path, "Elternbrief");
Assert.Equal("Elternbrief", Assert.Single(service.GetTemplates()).Name);
Assert.True(File.Exists(service.GetTemplatePath(imported)));
service.Delete(imported.Id);
Assert.Empty(service.GetTemplates());
Assert.False(File.Exists(service.GetTemplatePath(imported)));
}
private string CreateDocx(string body, string? header = null)
{
var path = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
using var archive = ZipFile.Open(path, ZipArchiveMode.Create);
AddXml(archive, "word/document.xml", body);
if (header is not null) AddXml(archive, "word/header1.xml", header);
return path;
}
private static void AddXml(ZipArchive archive, string name, string content)
{
var entry = archive.CreateEntry(name);
using var writer = new StreamWriter(entry.Open());
writer.Write($"<w:document xmlns:w=\"{W}\"><w:body>{content}</w:body></w:document>");
}
private static string Control(string tag) =>
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
"<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Platzhalter</w:t></w:r></w:p>" +
"</w:sdtContent></w:sdt>";
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
+52 -8
View File
@@ -847,10 +847,28 @@ Niveau-Zuordnung je Schüler (E/G/Förder, `GroupMembership.Niveau`) ist bereits
Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
### 7.1 Schüler
- [ ] **7.1.1** Schüler löschen / auf inaktiv setzen inkl. Hinweis auf verknüpfte Daten.
- [ ] **7.1.2** Suche in der Schülerliste (Name, Gruppe) mit Sofortfilter.
- [ ] **7.1.3** Foto/Avatar je Schüler (optional, lokal gespeichert) für schnellere Zuordnung.
- [ ] **7.1.4** Serienbrieffelder aus Kontaktdaten (Anrede, Adresse) für Elternbriefe.
- [x] **7.1.1** Schülerverwaltung im Schülerdetail: Deaktivieren blendet den Schüler aus der
normalen Schülerliste aus und bewahrt alle historischen Daten; deaktivierte Schüler können
wieder aktiviert werden. Ein Verwaltungsdialog zeigt die Anzahl verknüpfter Gruppenzuordnungen,
Klausurergebnisse, Einzel-/Zeugnisnoten, Mitarbeit- und Dokumentationseinträge. Endgültiges
Löschen ist nur ohne solche Verknüpfungen möglich und zusätzlich im `StudentRepository`
abgesichert, damit kein anderer Aufrufer verwaiste Daten erzeugen kann.
- [x] **7.1.2** Suche in der Schülerliste mit Sofortfilter über ein gemeinsames Suchfeld für
Vorname, Nachname, vollständigen Namen oder Lerngruppe. Passende Gruppen werden einmalig
auf ihre Mitgliedschaften abgebildet, statt pro Schüler separate Repository-Abfragen
auszuführen.
- [x] **7.1.3a** Datenschutzneutrale Standardavatare aus dem bereits gespeicherten Geschlecht:
männlich/weiblich klar unterscheidbar, neutraler Fallback für divers oder nicht angegeben.
Anzeige in Schülerliste, Gruppenschülerliste und Schülerdetail; das Geschlecht kann dort
nachträglich korrigiert werden. Keine Bilddateien und keine zusätzliche Einwilligung nötig.
- [ ] **7.1.3b** Echtes Foto je Schüler (optional, lokal gespeichert) — bewusst ganz nach hinten
gestellt, da die nötigen Foto-Einwilligungen im schulischen Alltag voraussichtlich nur in
seltenen Sonderfällen vorliegen.
- [x] **7.1.4** Serienbrieffelder aus Kontaktdaten für Elternbriefe: Kontakte speichern eine
ausdrückliche Briefanrede (keine fehleranfällige Ableitung aus Beziehung oder Name) sowie
die bereits vorhandenen getrennten Adressfelder. DOCX-Vorlagen verwenden Word-
Inhaltssteuerelemente mit dokumentierten Tags wie `Letter.Salutation`, `Contact.Address`,
`Student.FirstName` und `Group.Name`.
- [ ] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung.
### 7.2 Gruppen
@@ -860,8 +878,17 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
abhängigen Datensätze (inkl. Zeugnisnoten, gruppenspezifischem Notenschema und
Mitarbeitsabschnitten). Dokumentation, Aufgaben und Zeiteinträge bleiben als historische
Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt.
- [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft,
neues `SchoolYear`, neue `GroupMembership`-Einträge.
- [x] **7.2.2** Gruppe ins neue Schuljahr übernehmen — eigener Dialog im Verwaltungsmenü mit
vorgeschlagenem Folgeschuljahr, editierbarem Gruppennamen und erhöhter Klassenstufe.
Fach, Gruppentyp, Bewertungssystem, Wochenstunden, Eigene-Klasse-Markierung und
Niveaudifferenzierung werden übernommen; ein gruppenspezifisches Notenschema optional.
Die Schülerliste bleibt korrigierbar, vorausgewählt sind nur aktive Schüler, deren
Mitgliedschaft bis zum Ende des Ausgangsschuljahres läuft. Frühere Austritte und reine
H1-Mitglieder sind sichtbar, aber abgewählt; deaktivierte Schüler nicht übernehmbar. Neue
Mitgliedschaften beginnen am 1. August als Ganzjahresmitgliedschaft, behalten das Niveau
und erhalten kein Austrittsdatum. Leistungs-, Mitarbeit-, Planungs- und Stundenplandaten
bleiben ausschließlich an der alten Gruppe; diese kann nach erfolgreicher Übernahme
automatisch archiviert werden.
- [x] **7.2.3** Schüler aus einer Gruppe austragen, ohne die Mitgliedschaft zu löschen — eigener
Dialog mit `CalendarDatePicker`, aktuellem Datum als Vorbelegung und frei wählbarem
Austrittsdatum. Setzt `GroupMembership.LeftAt`; Eintrittsdatum, Niveau und historische
@@ -873,7 +900,16 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
Mitarbeits-Assistent/-Aggregation, Klausur-Punkteeingabe, Sammelnoten, Notenübersicht und
Zeugnisnoten. Ein Schüler zählt nur an Tagen bzw. in Zeiträumen, in denen die Mitgliedschaft
tatsächlich aktiv war; der Austrittstag selbst zählt noch als zugehörig.
- [ ] **7.2.5** Archivansicht abgeschlossener Schuljahre (schreibgeschützt).
- [x] **7.2.5** Archivansicht abgeschlossener Schuljahre mit konsequentem Schreibschutz.
Archivierte Gruppen und sämtliche historischen Schüler-, Noten-, Klausur-, Mitarbeit- und
Planungsdaten bleiben sichtbar. Verändernde Bedienelemente sind deaktiviert und ein
deutliches Banner bietet die ausdrückliche Wiederaktivierung mit Bestätigung an. Zusätzlich
erzwingt die Datenschicht den Schutz für Mitgliedschaften, Klausuren/-ergebnisse, Einzel- und
Zeugnisnoten, Notenschema, Mitarbeitssitzungen/-einträge/-abschnitte, Einheiten, Stunden und
Stundenplan-Slots; ein übersehener Dialog kann den UI-Schutz daher nicht umgehen. Auch die
Gruppeneigenschaften können erst in einem getrennten Schritt nach der Reaktivierung geändert
oder gelöscht werden. Reine Ansichten und das Kopieren einer Unterrichtseinheit als Vorlage
in eine aktive Gruppe bleiben möglich.
### 7.3 Import
- [ ] **7.3.1** CSV-Import von Schülerlisten mit Spaltenzuordnung im Dialog.
@@ -966,7 +1002,15 @@ Bisher nicht vorhanden — komplett neu.
- [ ] **11.3** PDF-Erzeugung (Bibliothek auswählen — z.B. QuestPDF) mit einheitlichem Layout.
- [ ] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
Schülerdokumentation.
- [ ] **11.5** Serienbrief/Elternbrief aus Kontaktdaten (Abhängigkeit zu 7.1.4).
- [x] **11.5a** Einzelner Elternbrief als bearbeitbare DOCX-Kopie aus einer in Word gestalteten
Vorlage. Vorlagenverwaltung in den Einstellungen; das Original bleibt unverändert. Vor
Import und erneut vor jeder Erzeugung werden Inhaltssteuerelemente auch in Kopf-/Fußzeilen
geprüft. Kein Steuerelement, fehlender Tag und unbekannte Tags erzeugen Hinweise;
wahrscheinliche Tippfehler werden per Ähnlichkeitsprüfung deutlich mit einem konkreten
Korrekturvorschlag gemeldet. Fehlende verwendete Kontakt-/Gruppenwerte blockieren die
Erzeugung, damit fehlerhafte Briefe nicht unbemerkt vervielfältigt werden.
- [ ] **11.5b** Stapelerzeugung für eine ganze Lerngruppe mit Kontaktauswahl je Schüler und
Ergebnisübersicht. Bewusst nachgelagert; der validierte Einzelbrief bildet die Grundlage.
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
---