Kapitel 7 abgeschlossen.
This commit is contained in:
@@ -7,6 +7,7 @@ public interface IStudentRepository
|
|||||||
Student? GetById(Guid id);
|
Student? GetById(Guid id);
|
||||||
List<Student> GetAll(bool includeInactive = false);
|
List<Student> GetAll(bool includeInactive = false);
|
||||||
List<Student> GetByGroup(Guid groupId);
|
List<Student> GetByGroup(Guid groupId);
|
||||||
|
StudentReferenceSummary GetReferenceSummary(Guid studentId);
|
||||||
void Save(Student student);
|
void Save(Student student);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class Contact
|
|||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Relation { 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? Phone { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Street { get; set; }
|
public string? Street { get; set; }
|
||||||
@@ -40,3 +42,16 @@ public enum ContactInvalidReason
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum Gender { M, W, D }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,37 @@ public sealed class RepositoryTests
|
|||||||
Assert.Equal(2, repo.GetAll(includeInactive: true).Count);
|
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 ───────────────────────────────────────────────────────
|
// ── GroupRepository ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -66,6 +97,53 @@ public sealed class RepositoryTests
|
|||||||
Assert.NotNull(groupRepo.GetAll(includeInactive: true).FirstOrDefault(g => g.Name == "Mathe G"));
|
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]
|
[Fact]
|
||||||
public void GroupRepository_Delete_LoeschtAlleAbhaengigenDatensaetzeKaskadierend()
|
public void GroupRepository_Delete_LoeschtAlleAbhaengigenDatensaetzeKaskadierend()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ using LehrerApp.Core.Models;
|
|||||||
|
|
||||||
namespace LehrerApp.Data.Repositories;
|
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 class StudentRepository(LiteDbContext db) : IStudentRepository
|
||||||
{
|
{
|
||||||
public Student? GetById(Guid id) => db.Students.FindById(id);
|
public Student? GetById(Guid id) => db.Students.FindById(id);
|
||||||
@@ -16,8 +28,22 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
.Select(e => e.StudentId).ToHashSet();
|
.Select(e => e.StudentId).ToHashSet();
|
||||||
return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList();
|
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 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
|
public class GroupRepository(LiteDbContext db) : IGroupRepository
|
||||||
@@ -33,6 +59,16 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
|
|||||||
.OrderBy(g => g.Name).ToList();
|
.OrderBy(g => g.Name).ToList();
|
||||||
public void Save(LearningGroup g)
|
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)
|
if (g.SubjectId is Guid subjectId && db.Subjects.FindById(subjectId) is null)
|
||||||
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
|
throw new InvalidOperationException("Das gewählte Fach existiert nicht mehr.");
|
||||||
g.UpdatedAt = DateTime.UtcNow;
|
g.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -40,6 +76,7 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
|
|||||||
}
|
}
|
||||||
public void Delete(Guid id)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
|
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
||||||
db.ExecuteInTransaction(() =>
|
db.ExecuteInTransaction(() =>
|
||||||
{
|
{
|
||||||
foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
|
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);
|
db.Memberships.FindOne(e => e.StudentId == studentId && e.GroupId == groupId);
|
||||||
public void Save(GroupMembership membership)
|
public void Save(GroupMembership membership)
|
||||||
{
|
{
|
||||||
|
ArchivedGroupWriteGuard.EnsureActive(db, membership.GroupId);
|
||||||
var existing = GetByStudentAndGroup(membership.StudentId, membership.GroupId);
|
var existing = GetByStudentAndGroup(membership.StudentId, membership.GroupId);
|
||||||
if (existing is not null && existing.Id != membership.Id)
|
if (existing is not null && existing.Id != membership.Id)
|
||||||
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
|
throw new InvalidOperationException("Der Schüler ist dieser Lerngruppe bereits zugeordnet.");
|
||||||
db.Memberships.Upsert(membership);
|
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
|
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> GetAll() => db.Exams.FindAll().OrderBy(e => e.Date).ToList();
|
||||||
public List<Exam> GetByGroup(Guid groupId) =>
|
public List<Exam> GetByGroup(Guid groupId) =>
|
||||||
db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList();
|
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)
|
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())
|
foreach (var result in db.ExamResults.Find(r => r.ExamId == id).ToList())
|
||||||
db.ExamResults.Delete(result.Id);
|
db.ExamResults.Delete(result.Id);
|
||||||
db.Exams.Delete(id);
|
db.Exams.Delete(id);
|
||||||
@@ -152,9 +202,18 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
|
|||||||
db.ExamResults.Find(r => r.StudentId == id).ToList();
|
db.ExamResults.Find(r => r.StudentId == id).ToList();
|
||||||
public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) =>
|
public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) =>
|
||||||
db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == 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)
|
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;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var r in results) r.UpdatedAt = now;
|
foreach (var r in results) r.UpdatedAt = now;
|
||||||
db.ExamResults.Upsert(results);
|
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();
|
db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList();
|
||||||
public List<Grade> GetByGroup(Guid id) =>
|
public List<Grade> GetByGroup(Guid id) =>
|
||||||
db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList();
|
db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList();
|
||||||
public void Save(Grade g) => db.Grades.Upsert(g);
|
public void Save(Grade g)
|
||||||
public void Delete(Guid id) => db.Grades.Delete(id);
|
{
|
||||||
|
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
|
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? GetByGroup(Guid groupId) => db.GradingSchemes.FindOne(s => s.GroupId == groupId);
|
||||||
public GradingScheme? GetDefaultForType(GroupType type) =>
|
public GradingScheme? GetDefaultForType(GroupType type) =>
|
||||||
db.GradingSchemes.FindOne(s => s.GroupId == null && s.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 Save(GradingScheme s)
|
||||||
public void Delete(Guid id) => db.GradingSchemes.Delete(id);
|
{
|
||||||
|
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
|
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 List<ReportGrade> GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList();
|
||||||
public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) =>
|
public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) =>
|
||||||
db.ReportGrades.FindOne(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == 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 Save(ReportGrade r)
|
||||||
public void Delete(Guid id) => db.ReportGrades.Delete(id);
|
{
|
||||||
|
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
|
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 Unit? GetById(Guid id) => db.Units.FindById(id);
|
||||||
public List<Unit> GetByGroup(Guid id) =>
|
public List<Unit> GetByGroup(Guid id) =>
|
||||||
db.Units.Find(u => u.GroupId == id).OrderBy(u => u.StartDate).ToList();
|
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 Save(Unit u)
|
||||||
public void Delete(Guid id) => db.Units.Delete(id);
|
{
|
||||||
|
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
|
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) =>
|
public List<Lesson> GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) =>
|
||||||
db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to)
|
db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to)
|
||||||
.OrderBy(l => l.Date).ToList();
|
.OrderBy(l => l.Date).ToList();
|
||||||
public void Save(Lesson l) { l.UpdatedAt = DateTime.UtcNow; db.Lessons.Upsert(l); }
|
public void Save(Lesson l)
|
||||||
public void Delete(Guid id) => db.Lessons.Delete(id);
|
{
|
||||||
|
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
|
public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository
|
||||||
@@ -276,9 +384,16 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
|||||||
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
||||||
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
||||||
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
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)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
|
if (db.ParticipationSessions.FindById(id) is { } session)
|
||||||
|
ArchivedGroupWriteGuard.EnsureActive(db, session.GroupId);
|
||||||
db.ParticipationSessions.Delete(id);
|
db.ParticipationSessions.Delete(id);
|
||||||
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList())
|
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == id).ToList())
|
||||||
db.ParticipationEntries.Delete(e.Id);
|
db.ParticipationEntries.Delete(e.Id);
|
||||||
@@ -293,15 +408,26 @@ public class ParticipationRepository(LiteDbContext db) : IParticipationRepositor
|
|||||||
db.ParticipationEntries.Find(e => e.StudentId == studentId).ToList();
|
db.ParticipationEntries.Find(e => e.StudentId == studentId).ToList();
|
||||||
public ParticipationEntry? GetBySessionAndStudent(Guid sessionId, Guid studentId) =>
|
public ParticipationEntry? GetBySessionAndStudent(Guid sessionId, Guid studentId) =>
|
||||||
db.ParticipationEntries.FindOne(e => e.SessionId == sessionId && e.StudentId == 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)
|
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;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var e in entries) e.UpdatedAt = now;
|
foreach (var e in entries) e.UpdatedAt = now;
|
||||||
db.ParticipationEntries.Upsert(entries);
|
db.ParticipationEntries.Upsert(entries);
|
||||||
}
|
}
|
||||||
public void DeleteBySession(Guid sessionId)
|
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())
|
foreach (var e in db.ParticipationEntries.Find(e => e.SessionId == sessionId).ToList())
|
||||||
db.ParticipationEntries.Delete(e.Id);
|
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();
|
db.ParticipationAspects.Find(a => a.GroupId == null && a.IsActive).OrderBy(a => a.SortOrder).ToList();
|
||||||
public List<ParticipationAspect> GetByGroup(Guid groupId) =>
|
public List<ParticipationAspect> GetByGroup(Guid groupId) =>
|
||||||
db.ParticipationAspects.Find(a => a.GroupId == groupId && a.IsActive).OrderBy(a => a.SortOrder).ToList();
|
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 Save(ParticipationAspect a)
|
||||||
public void Delete(Guid id) => db.ParticipationAspects.Delete(id);
|
{
|
||||||
|
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 class ParticipationSectionRepository(LiteDbContext db) : IParticipationSectionRepository
|
||||||
{
|
{
|
||||||
public List<ParticipationSection> GetByGroup(Guid groupId) =>
|
public List<ParticipationSection> GetByGroup(Guid groupId) =>
|
||||||
db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList();
|
db.ParticipationSections.Find(s => s.GroupId == groupId).OrderBy(s => s.StartDate).ToList();
|
||||||
public void Save(ParticipationSection s) => db.ParticipationSections.Upsert(s);
|
public void Save(ParticipationSection s)
|
||||||
public void Delete(Guid id) => db.ParticipationSections.Delete(id);
|
{
|
||||||
|
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
|
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();
|
db.TimetableSlots.Find(s => s.GroupId == groupId).OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList();
|
||||||
public void Save(TimetableSlot slot)
|
public void Save(TimetableSlot slot)
|
||||||
{
|
{
|
||||||
|
ArchivedGroupWriteGuard.EnsureActive(db, slot.GroupId);
|
||||||
var occupied = db.TimetableSlots.FindAll()
|
var occupied = db.TimetableSlots.FindAll()
|
||||||
.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber);
|
.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber);
|
||||||
if (occupied is not null && occupied.Id != slot.Id)
|
if (occupied is not null && occupied.Id != slot.Id)
|
||||||
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
|
||||||
db.TimetableSlots.Upsert(slot);
|
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
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,20 @@ namespace LehrerApp.Desktop.Tests;
|
|||||||
|
|
||||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
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 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 List<Student> GetByGroup(Guid groupId) => all;
|
||||||
public void Save(Student student) { }
|
public StudentReferenceSummary GetReferenceSummary(Guid studentId) =>
|
||||||
public void Delete(Guid id) { }
|
_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
|
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 LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties());
|
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||||
|
new LetterTemplateService(tempPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -100,7 +101,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||||
new FakeSupervisionDuties());
|
new FakeSupervisionDuties(), new LetterTemplateService(tempPath));
|
||||||
|
|
||||||
vm.SelectedStateName = "Bayern";
|
vm.SelectedStateName = "Bayern";
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties());
|
new FakeSupervisionDuties(), new LetterTemplateService(tempPath));
|
||||||
|
|
||||||
vm.PeriodTimes[0].StartText = "08:00";
|
vm.PeriodTimes[0].StartText = "08:00";
|
||||||
vm.PeriodTimes[0].EndText = "08:45";
|
vm.PeriodTimes[0].EndText = "08:45";
|
||||||
@@ -148,7 +149,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||||
new FakeSupervisionDuties());
|
new FakeSupervisionDuties(), new LetterTemplateService(tempPath));
|
||||||
|
|
||||||
vm.PeriodTimes[0].StartText = "08:45";
|
vm.PeriodTimes[0].StartText = "08:45";
|
||||||
vm.PeriodTimes[0].EndText = "08:00";
|
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 },
|
||||||
|
]));
|
||||||
|
}
|
||||||
@@ -140,10 +140,12 @@ public static class AppBootstrapper
|
|||||||
// ── Services ──────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────
|
||||||
services.AddSingleton<GradingService>();
|
services.AddSingleton<GradingService>();
|
||||||
services.AddSingleton<SchoolYearService>();
|
services.AddSingleton<SchoolYearService>();
|
||||||
|
services.AddSingleton<GroupRolloverService>();
|
||||||
services.AddSingleton<PublicHolidayService>();
|
services.AddSingleton<PublicHolidayService>();
|
||||||
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
|
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
|
||||||
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
||||||
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
||||||
|
services.AddSingleton(_ => new LetterTemplateService(appData));
|
||||||
|
|
||||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _showAsPoints = true;
|
[ObservableProperty] private bool _showAsPoints = true;
|
||||||
[ObservableProperty] private GradeOverviewRow? _selectedRow;
|
[ObservableProperty] private GradeOverviewRow? _selectedRow;
|
||||||
[ObservableProperty] private int _rebuildColumnsSignal;
|
[ObservableProperty] private int _rebuildColumnsSignal;
|
||||||
|
[ObservableProperty] private bool _isReadOnly;
|
||||||
|
|
||||||
public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15;
|
public bool CanTogglePointsView => _gradingSystem == GradingSystem.Points0To15;
|
||||||
|
|
||||||
@@ -64,13 +65,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType,
|
public void Initialize(Guid groupId, GradingSystem gradingSystem, GroupType groupType,
|
||||||
string groupLabel, string schoolYear)
|
string groupLabel, string schoolYear, bool isReadOnly = false)
|
||||||
{
|
{
|
||||||
_groupId = groupId;
|
_groupId = groupId;
|
||||||
_gradingSystem = gradingSystem;
|
_gradingSystem = gradingSystem;
|
||||||
_groupType = groupType;
|
_groupType = groupType;
|
||||||
_groupLabel = groupLabel;
|
_groupLabel = groupLabel;
|
||||||
_schoolYear = schoolYear;
|
_schoolYear = schoolYear;
|
||||||
|
IsReadOnly = isReadOnly;
|
||||||
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
|
ShowAsPoints = gradingSystem == GradingSystem.Points0To15;
|
||||||
OnPropertyChanged(nameof(CanTogglePointsView));
|
OnPropertyChanged(nameof(CanTogglePointsView));
|
||||||
Recompute();
|
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.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.ViewModels.Groups;
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
@@ -17,6 +18,7 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
public Action<Guid, int>? OnNavigateToDetail { get; set; }
|
public Action<Guid, int>? OnNavigateToDetail { get; set; }
|
||||||
public Func<Task>? OnAddGroup { get; set; }
|
public Func<Task>? OnAddGroup { get; set; }
|
||||||
public Func<Guid, Task>? OnEditGroup { 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; }
|
public Func<GroupListItem, Task<bool>>? OnConfirmDelete { get; set; }
|
||||||
|
|
||||||
[ObservableProperty] private string _selectedSchoolYear = "";
|
[ObservableProperty] private string _selectedSchoolYear = "";
|
||||||
@@ -53,6 +55,7 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(SelectedGroupSubtitle));
|
OnPropertyChanged(nameof(SelectedGroupSubtitle));
|
||||||
NavigateToSectionCommand.NotifyCanExecuteChanged();
|
NavigateToSectionCommand.NotifyCanExecuteChanged();
|
||||||
EditGroupCommand.NotifyCanExecuteChanged();
|
EditGroupCommand.NotifyCanExecuteChanged();
|
||||||
|
RollOverGroupCommand.NotifyCanExecuteChanged();
|
||||||
ToggleArchiveCommand.NotifyCanExecuteChanged();
|
ToggleArchiveCommand.NotifyCanExecuteChanged();
|
||||||
DeleteGroupCommand.NotifyCanExecuteChanged();
|
DeleteGroupCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,7 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
[RelayCommand] private void Refresh() => LoadGroups();
|
[RelayCommand] private void Refresh() => LoadGroups();
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
||||||
private async Task EditGroup()
|
private async Task EditGroup()
|
||||||
{
|
{
|
||||||
if (SelectedGroup is null || OnEditGroup is null) return;
|
if (SelectedGroup is null || OnEditGroup is null) return;
|
||||||
@@ -95,6 +98,20 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
SelectedGroup = Groups.FirstOrDefault(g => g.Id == id);
|
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))]
|
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
||||||
private void ToggleArchive()
|
private void ToggleArchive()
|
||||||
{
|
{
|
||||||
@@ -106,7 +123,7 @@ public partial class GroupListViewModel : ObservableObject
|
|||||||
LoadGroups();
|
LoadGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedGroup))]
|
[RelayCommand(CanExecute = nameof(CanEditSelectedGroup))]
|
||||||
private async Task DeleteGroup()
|
private async Task DeleteGroup()
|
||||||
{
|
{
|
||||||
if (SelectedGroup is null || OnConfirmDelete is null) return;
|
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 HasSelectedGroup() => SelectedGroup is not null;
|
||||||
|
private bool CanEditSelectedGroup() => SelectedGroup?.IsActive == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GroupListItem
|
public class GroupListItem
|
||||||
@@ -175,9 +193,17 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
// bevor LoadGroup() läuft (siehe MainWindowViewModel.NavigateToGroupDetail), Group ist dann
|
// 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.
|
// kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal einen Binding-Fehler loggen.
|
||||||
public bool IsDifferentiated => Group?.IsDifferentiated ?? false;
|
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; } = "";
|
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();
|
partial void OnShowFormerStudentsChanged(bool value) => LoadStudents();
|
||||||
|
|
||||||
public ObservableCollection<StudentSummary> Students { get; } = [];
|
public ObservableCollection<StudentSummary> Students { get; } = [];
|
||||||
@@ -194,6 +220,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
public Func<ExamSummary, Task<bool>>? OnConfirmDeleteExam { get; set; }
|
public Func<ExamSummary, Task<bool>>? OnConfirmDeleteExam { get; set; }
|
||||||
public Func<Exam, Task>? OnGradeExam { get; set; }
|
public Func<Exam, Task>? OnGradeExam { get; set; }
|
||||||
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
public Func<Exam, Task>? OnEvaluateExam { get; set; }
|
||||||
|
public Func<Task<bool>>? OnConfirmReactivate { get; set; }
|
||||||
|
|
||||||
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
|
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
|
||||||
IGroupMembershipRepository memberships, ISubjectRepository subjects,
|
IGroupMembershipRepository memberships, ISubjectRepository subjects,
|
||||||
@@ -221,9 +248,9 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15")}";
|
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "1–6" : "0–15")}";
|
||||||
LoadStudents();
|
LoadStudents();
|
||||||
ReloadExams();
|
ReloadExams();
|
||||||
ParticipationTab.Initialize(Group.Id, Group.SchoolYear);
|
ParticipationTab.Initialize(Group.Id, Group.SchoolYear, IsReadOnly);
|
||||||
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear);
|
GradeOverviewTab.Initialize(Group.Id, Group.GradingSystem, Group.Type, GroupTitle, Group.SchoolYear, IsReadOnly);
|
||||||
PlanningTab.Initialize(Group.Id);
|
PlanningTab.Initialize(Group.Id, IsReadOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReloadExams()
|
private void ReloadExams()
|
||||||
@@ -247,7 +274,11 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
memberships.TryGetValue(s.Id, out var membership);
|
memberships.TryGetValue(s.Id, out var membership);
|
||||||
if (membership is null) continue;
|
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;
|
if (summary.IsFormer && !ShowFormerStudents) continue;
|
||||||
Students.Add(summary);
|
Students.Add(summary);
|
||||||
}
|
}
|
||||||
@@ -255,14 +286,14 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
|
|
||||||
private void SaveStudentNiveau(StudentSummary summary)
|
private void SaveStudentNiveau(StudentSummary summary)
|
||||||
{
|
{
|
||||||
if (Group is null) return;
|
if (!IsEditable || Group is null) return;
|
||||||
var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
|
var membership = _memberships.GetByStudentAndGroup(summary.Id, Group.Id);
|
||||||
if (membership is null) return;
|
if (membership is null) return;
|
||||||
membership.Niveau = summary.Niveau;
|
membership.Niveau = summary.Niveau;
|
||||||
_memberships.Save(membership);
|
_memberships.Save(membership);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
|
||||||
private async Task AddStudent()
|
private async Task AddStudent()
|
||||||
{
|
{
|
||||||
if (OnAddStudent is null) return;
|
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()
|
private async Task WithdrawStudent()
|
||||||
{
|
{
|
||||||
if (SelectedStudent is null || OnWithdrawStudent is null) return;
|
if (SelectedStudent is null || OnWithdrawStudent is null) return;
|
||||||
@@ -284,7 +315,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
ParticipationTab.RefreshCurrentGrid();
|
ParticipationTab.RefreshCurrentGrid();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanReinstateSelectedStudent))]
|
[RelayCommand(CanExecute = nameof(CanEditAndReinstateSelectedStudent))]
|
||||||
private void ReinstateStudent()
|
private void ReinstateStudent()
|
||||||
{
|
{
|
||||||
if (Group is null || SelectedStudent is null) return;
|
if (Group is null || SelectedStudent is null) return;
|
||||||
@@ -303,10 +334,10 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
ReinstateStudentCommand.NotifyCanExecuteChanged();
|
ReinstateStudentCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HasSelectedStudent() => SelectedStudent is not null;
|
private bool CanEditSelectedStudent() => IsEditable && SelectedStudent is not null;
|
||||||
private bool CanReinstateSelectedStudent() => SelectedStudent?.HasExitDate == true;
|
private bool CanEditAndReinstateSelectedStudent() => IsEditable && SelectedStudent?.HasExitDate == true;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(IsEditableGroup))]
|
||||||
private async Task AddExam()
|
private async Task AddExam()
|
||||||
{
|
{
|
||||||
if (Group is null || OnAddExam is null) return;
|
if (Group is null || OnAddExam is null) return;
|
||||||
@@ -314,7 +345,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
if (saved) ReloadExams();
|
if (saved) ReloadExams();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||||
private async Task EditExam()
|
private async Task EditExam()
|
||||||
{
|
{
|
||||||
if (SelectedExam is null || OnEditExam is null) return;
|
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()
|
private async Task GradeExam()
|
||||||
{
|
{
|
||||||
if (SelectedExam is null || OnGradeExam is null) return;
|
if (SelectedExam is null || OnGradeExam is null) return;
|
||||||
@@ -338,7 +369,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
await OnGradeExam(exam);
|
await OnGradeExam(exam);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||||
private async Task EvaluateExam()
|
private async Task EvaluateExam()
|
||||||
{
|
{
|
||||||
if (SelectedExam is null || OnEvaluateExam is null) return;
|
if (SelectedExam is null || OnEvaluateExam is null) return;
|
||||||
@@ -347,7 +378,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
await OnEvaluateExam(exam);
|
await OnEvaluateExam(exam);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||||
private async Task DuplicateExam()
|
private async Task DuplicateExam()
|
||||||
{
|
{
|
||||||
if (SelectedExam is null || OnDuplicateExam is null) return;
|
if (SelectedExam is null || OnDuplicateExam is null) return;
|
||||||
@@ -357,7 +388,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
if (saved) ReloadExams();
|
if (saved) ReloadExams();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||||
private async Task DeleteExam()
|
private async Task DeleteExam()
|
||||||
{
|
{
|
||||||
if (SelectedExam is null || OnConfirmDeleteExam is null) return;
|
if (SelectedExam is null || OnConfirmDeleteExam is null) return;
|
||||||
@@ -368,7 +399,7 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
ReloadExams();
|
ReloadExams();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(HasSelectedExam))]
|
[RelayCommand(CanExecute = nameof(CanEditSelectedExam))]
|
||||||
private void AdvanceExamStatus()
|
private void AdvanceExamStatus()
|
||||||
{
|
{
|
||||||
if (SelectedExam is null) return;
|
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)
|
private void SetExamStatus(ExamStatus status)
|
||||||
{
|
{
|
||||||
if (SelectedExam is null) return;
|
if (SelectedExam is null) return;
|
||||||
@@ -409,7 +440,35 @@ public partial class GroupDetailViewModel : ObservableObject
|
|||||||
SetExamStatusCommand.NotifyCanExecuteChanged();
|
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); }
|
[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 bool HasExitDate { get; }
|
||||||
public string MembershipStatus { get; }
|
public string MembershipStatus { get; }
|
||||||
public string WithdrawActionLabel => HasExitDate ? "Austrittsdatum ändern" : "Austragen";
|
public string WithdrawActionLabel => HasExitDate ? "Austrittsdatum ändern" : "Austragen";
|
||||||
|
public string AvatarLabel { get; }
|
||||||
|
public string AvatarColor { get; }
|
||||||
|
public string AvatarTooltip { get; }
|
||||||
|
|
||||||
[ObservableProperty] private Niveau? _niveau;
|
[ObservableProperty] private Niveau? _niveau;
|
||||||
|
|
||||||
@@ -458,11 +520,15 @@ public partial class StudentSummary : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Action<StudentSummary>? OnChanged { get; set; }
|
public Action<StudentSummary>? OnChanged { get; set; }
|
||||||
|
public bool CanEdit { get; init; } = true;
|
||||||
|
|
||||||
public StudentSummary(Core.Models.Student s, GroupMembership? membership, DateOnly? today = null)
|
public StudentSummary(Core.Models.Student s, GroupMembership? membership, DateOnly? today = null)
|
||||||
{
|
{
|
||||||
Id = s.Id;
|
Id = s.Id;
|
||||||
FullName = s.FullName;
|
FullName = s.FullName;
|
||||||
|
AvatarLabel = GenderAvatarDisplay.Label(s.Gender);
|
||||||
|
AvatarColor = GenderAvatarDisplay.Color(s.Gender);
|
||||||
|
AvatarTooltip = GenderAvatarDisplay.Tooltip(s.Gender);
|
||||||
PeriodLabel = membership is null ? "" : BuildPeriodLabel(membership);
|
PeriodLabel = membership is null ? "" : BuildPeriodLabel(membership);
|
||||||
HasExitDate = membership?.LeftAt.HasValue == true;
|
HasExitDate = membership?.LeftAt.HasValue == true;
|
||||||
IsFormer = membership?.LeftAt is { } leftAt
|
IsFormer = membership?.LeftAt is { } leftAt
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _hasCompetencyCatalog;
|
[ObservableProperty] private bool _hasCompetencyCatalog;
|
||||||
[ObservableProperty] private bool _studentCompetencyRatingsVisible;
|
[ObservableProperty] private bool _studentCompetencyRatingsVisible;
|
||||||
[ObservableProperty] private int _rebuildColumnsSignal;
|
[ObservableProperty] private int _rebuildColumnsSignal;
|
||||||
|
[ObservableProperty] private bool _isReadOnly;
|
||||||
|
|
||||||
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
|
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
|
||||||
public List<string> ActiveCompetencyCodes { get; private set; } = [];
|
public List<string> ActiveCompetencyCodes { get; private set; } = [];
|
||||||
@@ -67,10 +68,11 @@ public partial class ParticipationTabViewModel : ObservableObject
|
|||||||
_competencyDomains = competencyDomains;
|
_competencyDomains = competencyDomains;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(Guid groupId, string schoolYear)
|
public void Initialize(Guid groupId, string schoolYear, bool isReadOnly = false)
|
||||||
{
|
{
|
||||||
_groupId = groupId;
|
_groupId = groupId;
|
||||||
_schoolYear = schoolYear;
|
_schoolYear = schoolYear;
|
||||||
|
IsReadOnly = isReadOnly;
|
||||||
|
|
||||||
var group = _groups.GetById(groupId);
|
var group = _groups.GetById(groupId);
|
||||||
_subjectId = group?.SubjectId;
|
_subjectId = group?.SubjectId;
|
||||||
@@ -160,6 +162,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
|||||||
|
|
||||||
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value)
|
private void SaveRating(Guid sessionId, Guid studentId, string key, int? value)
|
||||||
{
|
{
|
||||||
|
if (IsReadOnly) return;
|
||||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||||
?? new ParticipationEntry
|
?? new ParticipationEntry
|
||||||
{
|
{
|
||||||
@@ -187,6 +190,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
|||||||
|
|
||||||
private void SaveHomework(Guid sessionId, Guid studentId, HomeworkStatus? value)
|
private void SaveHomework(Guid sessionId, Guid studentId, HomeworkStatus? value)
|
||||||
{
|
{
|
||||||
|
if (IsReadOnly) return;
|
||||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||||
entry.Homework = value;
|
entry.Homework = value;
|
||||||
@@ -196,6 +200,7 @@ public partial class ParticipationTabViewModel : ObservableObject
|
|||||||
|
|
||||||
private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value)
|
private void SaveAttendance(Guid sessionId, Guid studentId, AttendanceStatus? value)
|
||||||
{
|
{
|
||||||
|
if (IsReadOnly) return;
|
||||||
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
|
||||||
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
|
||||||
entry.Attendance = value;
|
entry.Attendance = value;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private UnitSummary? _selectedUnit;
|
[ObservableProperty] private UnitSummary? _selectedUnit;
|
||||||
[ObservableProperty] private LessonSummary? _selectedLesson;
|
[ObservableProperty] private LessonSummary? _selectedLesson;
|
||||||
|
[ObservableProperty] private bool _isReadOnly;
|
||||||
|
|
||||||
// Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen
|
// Eigene Property statt "SelectedUnit.Title" im Binding-Pfad: SelectedUnit ist zwischen
|
||||||
// Gruppenwechsel/Laden kurzzeitig null — ein verschachtelter Pfad würde dafür jedes Mal
|
// 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;
|
_subjects = subjects; _competencyDomains = competencyDomains;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(Guid groupId)
|
public void Initialize(Guid groupId, bool isReadOnly = false)
|
||||||
{
|
{
|
||||||
_groupId = groupId;
|
_groupId = groupId;
|
||||||
|
IsReadOnly = isReadOnly;
|
||||||
var group = _groups.GetById(groupId);
|
var group = _groups.GetById(groupId);
|
||||||
SubjectId = group?.SubjectId;
|
SubjectId = group?.SubjectId;
|
||||||
GradeLevel = group?.GradeLevel ?? 0;
|
GradeLevel = group?.GradeLevel ?? 0;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
NavItem.Dashboard => GetDashboard(),
|
NavItem.Dashboard => GetDashboard(),
|
||||||
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
|
||||||
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
|
NavItem.Students => GetStudents(),
|
||||||
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
|
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
|
||||||
NavItem.Planner => GetTimetable(),
|
NavItem.Planner => GetTimetable(),
|
||||||
NavItem.Workload => GetWorkload(),
|
NavItem.Workload => GetWorkload(),
|
||||||
@@ -80,6 +80,14 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
return dashboard;
|
return dashboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private StudentListViewModel GetStudents()
|
||||||
|
{
|
||||||
|
var students = _services.GetRequiredService<StudentListViewModel>();
|
||||||
|
students.SelectedStudent = null;
|
||||||
|
students.LoadStudents();
|
||||||
|
return students;
|
||||||
|
}
|
||||||
|
|
||||||
private TimetableViewModel GetTimetable()
|
private TimetableViewModel GetTimetable()
|
||||||
{
|
{
|
||||||
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
||||||
@@ -120,9 +128,12 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
ActiveNavItem = NavItem.Students;
|
ActiveNavItem = NavItem.Students;
|
||||||
var vm = _services.GetRequiredService<StudentDetailViewModel>();
|
var vm = _services.GetRequiredService<StudentDetailViewModel>();
|
||||||
|
vm.OnReturnToStudentList = NavigateToStudents;
|
||||||
vm.LoadStudent(studentId);
|
vm.LoadStudent(studentId);
|
||||||
CurrentPage = vm;
|
CurrentPage = vm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void NavigateToStudents() => NavigateTo(NavItem.Students);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
|
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 IDocumentationRepository _documentation;
|
||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IShorthandCodeRepository _shorthandCodes;
|
private readonly IShorthandCodeRepository _shorthandCodes;
|
||||||
|
private readonly LetterTemplateService _letterTemplates;
|
||||||
|
|
||||||
[ObservableProperty] private int _activeTabIndex;
|
[ObservableProperty] private int _activeTabIndex;
|
||||||
|
|
||||||
@@ -67,6 +68,13 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
public List<string> GradingSystemOptions { get; } = ["Noten 1–6", "Punkte 0–15"];
|
||||||
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
|
||||||
|
|
||||||
|
// ── 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) ───────────────────────────
|
// ── Gewichtungsschema-Voreinstellungen (2.3.3) ───────────────────────────
|
||||||
|
|
||||||
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
||||||
@@ -160,7 +168,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
IDocumentationRepository documentation, IStudentRepository students,
|
IDocumentationRepository documentation, IStudentRepository students,
|
||||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||||
ISupervisionDutyRepository supervisionDuties)
|
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates)
|
||||||
{
|
{
|
||||||
_subjects = subjects;
|
_subjects = subjects;
|
||||||
_domainRepo = domainRepo;
|
_domainRepo = domainRepo;
|
||||||
@@ -179,6 +187,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_calendarSettings = calendarSettings;
|
_calendarSettings = calendarSettings;
|
||||||
_periodSchedule = periodSchedule;
|
_periodSchedule = periodSchedule;
|
||||||
_supervisionDuties = supervisionDuties;
|
_supervisionDuties = supervisionDuties;
|
||||||
|
_letterTemplates = letterTemplates;
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
LoadShorthandCodes();
|
LoadShorthandCodes();
|
||||||
LoadGradingKeyTemplates();
|
LoadGradingKeyTemplates();
|
||||||
@@ -193,6 +202,55 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
LoadSchoolHolidays();
|
LoadSchoolHolidays();
|
||||||
LoadPeriodTimes();
|
LoadPeriodTimes();
|
||||||
LoadSupervisionDuties();
|
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 ─────────────────────────────
|
// ── 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 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 class ExpiredDocumentItem(Documentation d, string studentName)
|
||||||
{
|
{
|
||||||
public Guid Id { get; } = d.Id;
|
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;
|
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
|
public partial class StudentListViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
|
private readonly IGroupMembershipRepository _memberships;
|
||||||
public Action<Guid>? OnNavigateToDetail { get; set; }
|
public Action<Guid>? OnNavigateToDetail { get; set; }
|
||||||
|
|
||||||
[ObservableProperty] private string _searchText = "";
|
[ObservableProperty] private string _searchText = "";
|
||||||
@@ -21,9 +68,12 @@ public partial class StudentListViewModel : ObservableObject
|
|||||||
public ObservableCollection<StudentListItem> Students { get; } = [];
|
public ObservableCollection<StudentListItem> Students { get; } = [];
|
||||||
public string CountSummary => $"{Students.Count} Schüler gesamt";
|
public string CountSummary => $"{Students.Count} Schüler gesamt";
|
||||||
|
|
||||||
public StudentListViewModel(IStudentRepository students)
|
public StudentListViewModel(IStudentRepository students, IGroupRepository groups,
|
||||||
|
IGroupMembershipRepository memberships)
|
||||||
{
|
{
|
||||||
_students = students;
|
_students = students;
|
||||||
|
_groups = groups;
|
||||||
|
_memberships = memberships;
|
||||||
LoadStudents();
|
LoadStudents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +88,21 @@ public partial class StudentListViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Students.Clear();
|
Students.Clear();
|
||||||
var all = _students.GetAll(ShowInactive);
|
var all = _students.GetAll(ShowInactive);
|
||||||
var f = string.IsNullOrWhiteSpace(SearchText) ? all
|
var query = SearchText.Trim();
|
||||||
: all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|
IEnumerable<Student> f = all;
|
||||||
|| s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
|
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));
|
foreach (var s in f) Students.Add(new StudentListItem(s));
|
||||||
OnPropertyChanged(nameof(CountSummary));
|
OnPropertyChanged(nameof(CountSummary));
|
||||||
}
|
}
|
||||||
@@ -63,10 +125,92 @@ public class StudentListItem
|
|||||||
public Guid Id { get; }
|
public Guid Id { get; }
|
||||||
public string FullName { get; }
|
public string FullName { get; }
|
||||||
public string DateOfBirth { get; }
|
public string DateOfBirth { get; }
|
||||||
|
public string AvatarLabel { get; }
|
||||||
|
public string AvatarColor { get; }
|
||||||
|
public string AvatarTooltip { get; }
|
||||||
public StudentListItem(Student s)
|
public StudentListItem(Student s)
|
||||||
{
|
{
|
||||||
Id = s.Id; FullName = s.FullName;
|
Id = s.Id; FullName = s.FullName;
|
||||||
DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? "";
|
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 bool _isEditing;
|
||||||
[ObservableProperty] private string _editFirstName = "";
|
[ObservableProperty] private string _editFirstName = "";
|
||||||
[ObservableProperty] private string _editLastName = "";
|
[ObservableProperty] private string _editLastName = "";
|
||||||
|
[ObservableProperty] private string _editGender = "";
|
||||||
[ObservableProperty] private ContactItem? _selectedContact;
|
[ObservableProperty] private ContactItem? _selectedContact;
|
||||||
[ObservableProperty] private AttendanceBalance? _attendance;
|
[ObservableProperty] private AttendanceBalance? _attendance;
|
||||||
[ObservableProperty] private string _exportStatus = "";
|
[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<GroupMembershipEntry> GroupMemberships { get; } = [];
|
||||||
public ObservableCollection<DocumentationItem> Documentation { 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<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
|
||||||
public Func<string, Task>? OnSaveExportFile { get; set; }
|
public Func<string, Task>? OnSaveExportFile { get; set; }
|
||||||
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { 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,
|
public StudentDetailViewModel(IStudentRepository students,
|
||||||
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
|
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
|
||||||
@@ -126,8 +283,13 @@ public partial class StudentDetailViewModel : ObservableObject
|
|||||||
Student = _students.GetById(id);
|
Student = _students.GetById(id);
|
||||||
if (Student is null) return;
|
if (Student is null) return;
|
||||||
StudentTitle = Student.FullName;
|
StudentTitle = Student.FullName;
|
||||||
|
IsStudentActive = Student.IsActive;
|
||||||
EditFirstName = Student.FirstName;
|
EditFirstName = Student.FirstName;
|
||||||
EditLastName = Student.LastName;
|
EditLastName = Student.LastName;
|
||||||
|
EditGender = GenderAvatarDisplay.ToOption(Student.Gender);
|
||||||
|
OnPropertyChanged(nameof(StudentAvatarLabel));
|
||||||
|
OnPropertyChanged(nameof(StudentAvatarColor));
|
||||||
|
OnPropertyChanged(nameof(StudentAvatarTooltip));
|
||||||
|
|
||||||
GroupMemberships.Clear();
|
GroupMemberships.Clear();
|
||||||
GradeHistory.Clear();
|
GradeHistory.Clear();
|
||||||
@@ -269,18 +431,44 @@ public partial class StudentDetailViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private void StartEdit() => IsEditing = true;
|
[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()
|
[RelayCommand] private void CancelEdit()
|
||||||
{
|
{
|
||||||
if (Student is null) return;
|
if (Student is null) return;
|
||||||
EditFirstName = Student.FirstName; EditLastName = Student.LastName;
|
EditFirstName = Student.FirstName; EditLastName = Student.LastName;
|
||||||
|
EditGender = GenderAvatarDisplay.ToOption(Student.Gender);
|
||||||
IsEditing = false;
|
IsEditing = false;
|
||||||
}
|
}
|
||||||
[RelayCommand] private void SaveEdit()
|
[RelayCommand] private void SaveEdit()
|
||||||
{
|
{
|
||||||
if (Student is null) return;
|
if (Student is null) return;
|
||||||
Student.FirstName = EditFirstName; Student.LastName = EditLastName;
|
Student.FirstName = EditFirstName; Student.LastName = EditLastName;
|
||||||
|
Student.Gender = GenderAvatarDisplay.FromOption(EditGender);
|
||||||
_students.Save(Student);
|
_students.Save(Student);
|
||||||
StudentTitle = Student.FullName;
|
StudentTitle = Student.FullName;
|
||||||
|
OnPropertyChanged(nameof(StudentAvatarLabel));
|
||||||
|
OnPropertyChanged(nameof(StudentAvatarColor));
|
||||||
|
OnPropertyChanged(nameof(StudentAvatarTooltip));
|
||||||
IsEditing = false;
|
IsEditing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +570,7 @@ public class ContactItem
|
|||||||
public Guid Id => Model.Id;
|
public Guid Id => Model.Id;
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
public string Relation { get; }
|
public string Relation { get; }
|
||||||
|
public string LetterSalutation { get; }
|
||||||
public string? Phone { get; }
|
public string? Phone { get; }
|
||||||
public string? Email { get; }
|
public string? Email { get; }
|
||||||
public string Address { get; }
|
public string Address { get; }
|
||||||
@@ -404,6 +593,7 @@ public class ContactItem
|
|||||||
Model = c;
|
Model = c;
|
||||||
Name = c.Name;
|
Name = c.Name;
|
||||||
Relation = c.Relation;
|
Relation = c.Relation;
|
||||||
|
LetterSalutation = c.LetterSalutation ?? "";
|
||||||
Phone = c.Phone;
|
Phone = c.Phone;
|
||||||
Email = c.Email;
|
Email = c.Email;
|
||||||
Address = FormatAddress(c);
|
Address = FormatAddress(c);
|
||||||
@@ -449,7 +639,7 @@ public partial class AddStudentDialogViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _firstNameError = "";
|
[ObservableProperty] private string _firstNameError = "";
|
||||||
[ObservableProperty] private string _dateOfBirthError = "";
|
[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 List<string> RelationPresets { get; } = ["Schüler/in", "Mutter", "Vater", "Elternteil", "Erziehungsberechtigte/r", "Sonstige"];
|
||||||
|
|
||||||
public ObservableCollection<ContactEntryViewModel> Contacts { get; } = [];
|
public ObservableCollection<ContactEntryViewModel> Contacts { get; } = [];
|
||||||
@@ -490,13 +680,7 @@ public partial class AddStudentDialogViewModel : ObservableObject
|
|||||||
FirstName = FirstName.Trim(),
|
FirstName = FirstName.Trim(),
|
||||||
LastName = LastName.Trim(),
|
LastName = LastName.Trim(),
|
||||||
DateOfBirth = dob,
|
DateOfBirth = dob,
|
||||||
Gender = SelectedGender switch
|
Gender = GenderAvatarDisplay.FromOption(SelectedGender),
|
||||||
{
|
|
||||||
"M – männlich" => Gender.M,
|
|
||||||
"W – weiblich" => Gender.W,
|
|
||||||
"D – divers" => Gender.D,
|
|
||||||
_ => (Gender?)null,
|
|
||||||
},
|
|
||||||
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
|
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
|
||||||
Contacts = Contacts.Select(c => c.ToModel()).Where(c => !string.IsNullOrWhiteSpace(c.Name)).ToList(),
|
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 _name = "";
|
||||||
[ObservableProperty] private string _relation = "";
|
[ObservableProperty] private string _relation = "";
|
||||||
|
[ObservableProperty] private string _letterSalutation = "";
|
||||||
[ObservableProperty] private string _phone = "";
|
[ObservableProperty] private string _phone = "";
|
||||||
[ObservableProperty] private string _email = "";
|
[ObservableProperty] private string _email = "";
|
||||||
[ObservableProperty] private string _street = "";
|
[ObservableProperty] private string _street = "";
|
||||||
@@ -524,6 +709,7 @@ public partial class ContactEntryViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Name = Name.Trim(),
|
Name = Name.Trim(),
|
||||||
Relation = Relation.Trim(),
|
Relation = Relation.Trim(),
|
||||||
|
LetterSalutation = string.IsNullOrWhiteSpace(LetterSalutation) ? null : LetterSalutation.Trim(),
|
||||||
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
|
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
|
||||||
Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(),
|
Email = string.IsNullOrWhiteSpace(Email) ? null : Email.Trim(),
|
||||||
Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(),
|
Street = string.IsNullOrWhiteSpace(Street) ? null : Street.Trim(),
|
||||||
@@ -538,6 +724,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private string _relation = "Elternteil";
|
[ObservableProperty] private string _relation = "Elternteil";
|
||||||
|
[ObservableProperty] private string _letterSalutation = "";
|
||||||
[ObservableProperty] private string _phone = "";
|
[ObservableProperty] private string _phone = "";
|
||||||
[ObservableProperty] private string _email = "";
|
[ObservableProperty] private string _email = "";
|
||||||
[ObservableProperty] private string _street = "";
|
[ObservableProperty] private string _street = "";
|
||||||
@@ -564,6 +751,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
if (source is null) return;
|
if (source is null) return;
|
||||||
Name = source.Name;
|
Name = source.Name;
|
||||||
Relation = source.Relation;
|
Relation = source.Relation;
|
||||||
|
LetterSalutation = source.LetterSalutation ?? "";
|
||||||
Phone = source.Phone ?? "";
|
Phone = source.Phone ?? "";
|
||||||
Email = source.Email ?? "";
|
Email = source.Email ?? "";
|
||||||
Street = source.Street ?? "";
|
Street = source.Street ?? "";
|
||||||
@@ -614,6 +802,7 @@ public partial class ContactEditDialogViewModel : ObservableObject
|
|||||||
Id = _source?.Id ?? Guid.NewGuid(),
|
Id = _source?.Id ?? Guid.NewGuid(),
|
||||||
Name = Name.Trim(),
|
Name = Name.Trim(),
|
||||||
Relation = Relation.Trim(),
|
Relation = Relation.Trim(),
|
||||||
|
LetterSalutation = NullIfEmpty(LetterSalutation),
|
||||||
Phone = NullIfEmpty(Phone),
|
Phone = NullIfEmpty(Phone),
|
||||||
Email = NullIfEmpty(Email),
|
Email = NullIfEmpty(Email),
|
||||||
Street = NullIfEmpty(Street),
|
Street = NullIfEmpty(Street),
|
||||||
|
|||||||
@@ -17,9 +17,10 @@
|
|||||||
<Button Content="Sortierung: Gesamt" Command="{Binding SortByTotalCommand}"/>
|
<Button Content="Sortierung: Gesamt" Command="{Binding SortByTotalCommand}"/>
|
||||||
|
|
||||||
<Button Content="Noten verwalten" Command="{Binding ManageStudentGradesCommand}" Margin="12,0,0,0"
|
<Button Content="Noten verwalten" Command="{Binding ManageStudentGradesCommand}" Margin="12,0,0,0"
|
||||||
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
IsVisible="{Binding SelectedRow, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||||
<Button Content="+ Sammelnote" Command="{Binding CollectiveGradeCommand}"/>
|
IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}"/>
|
<Button Content="+ Sammelnote" Command="{Binding CollectiveGradeCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
|
<Button Content="Zeugnisnoten" Command="{Binding ReportGradesCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<DataGrid Grid.Row="1"
|
<DataGrid Grid.Row="1"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
|
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
|
||||||
x:DataType="vm:GroupDetailViewModel">
|
x:DataType="vm:GroupDetailViewModel">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*">
|
<Grid RowDefinitions="Auto,Auto,*">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<Border Grid.Row="0" Padding="20,16"
|
<Border Grid.Row="0" Padding="20,16"
|
||||||
@@ -22,18 +22,32 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
|
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}"/>
|
<Button Content="+ Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
|
||||||
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
|
||||||
Command="{Binding WithdrawStudentCommand}"
|
Command="{Binding WithdrawStudentCommand}"
|
||||||
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
|
<Button Content="Austragung zurücknehmen" Command="{Binding ReinstateStudentCommand}"
|
||||||
IsVisible="{Binding SelectedStudent.HasExitDate}"/>
|
IsVisible="{Binding SelectedStudent.HasExitDate}"/>
|
||||||
<Button Content="+ Klausur" Command="{Binding AddExamCommand}"/>
|
<Button Content="+ Klausur" Command="{Binding AddExamCommand}" IsEnabled="{Binding IsEditable}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</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 -->
|
<!-- Tab: Übersicht -->
|
||||||
<ContentPage Header="Übersicht">
|
<ContentPage Header="Übersicht">
|
||||||
@@ -57,6 +71,18 @@
|
|||||||
CanUserResizeColumns="True"
|
CanUserResizeColumns="True"
|
||||||
Margin="0">
|
Margin="0">
|
||||||
<DataGrid.Columns>
|
<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="Name" Binding="{Binding FullName}" Width="*"/>
|
||||||
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="190"/>
|
<DataGridTextColumn Header="Zeitraum" Binding="{Binding PeriodLabel}" Width="190"/>
|
||||||
<DataGridTextColumn Header="Status" Binding="{Binding MembershipStatus}" Width="170"/>
|
<DataGridTextColumn Header="Status" Binding="{Binding MembershipStatus}" Width="170"/>
|
||||||
@@ -66,6 +92,7 @@
|
|||||||
<DataTemplate x:DataType="vm:StudentSummary">
|
<DataTemplate x:DataType="vm:StudentSummary">
|
||||||
<ComboBox ItemsSource="{x:Static vm:StudentSummary.NiveauOptions}"
|
<ComboBox ItemsSource="{x:Static vm:StudentSummary.NiveauOptions}"
|
||||||
SelectedItem="{Binding NiveauName}"
|
SelectedItem="{Binding NiveauName}"
|
||||||
|
IsEnabled="{Binding CanEdit}"
|
||||||
HorizontalAlignment="Stretch" Margin="2"/>
|
HorizontalAlignment="Stretch" Margin="2"/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
@@ -83,7 +110,8 @@
|
|||||||
<ContentPage Header="Klausuren">
|
<ContentPage Header="Klausuren">
|
||||||
<Grid RowDefinitions="Auto,*">
|
<Grid RowDefinitions="Auto,*">
|
||||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8"
|
<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="Punkte eingeben" Command="{Binding GradeExamCommand}"/>
|
||||||
<Button Content="Auswertung" Command="{Binding EvaluateExamCommand}"/>
|
<Button Content="Auswertung" Command="{Binding EvaluateExamCommand}"/>
|
||||||
<Button Content="Bearbeiten" Command="{Binding EditExamCommand}"/>
|
<Button Content="Bearbeiten" Command="{Binding EditExamCommand}"/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using LehrerApp.Core.Interfaces;
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
@@ -24,9 +25,25 @@ public partial class GroupDetailView : UserControl
|
|||||||
vm.OnConfirmDeleteExam = ShowDeleteExamDialog;
|
vm.OnConfirmDeleteExam = ShowDeleteExamDialog;
|
||||||
vm.OnGradeExam = ShowGradeExamDialog;
|
vm.OnGradeExam = ShowGradeExamDialog;
|
||||||
vm.OnEvaluateExam = ShowEvaluateExamDialog;
|
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()
|
private async Task<bool> ShowAddStudentDialog()
|
||||||
{
|
{
|
||||||
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
|
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
|
||||||
|
|||||||
@@ -91,6 +91,8 @@
|
|||||||
<MenuFlyout>
|
<MenuFlyout>
|
||||||
<MenuItem Header="Details bearbeiten"
|
<MenuItem Header="Details bearbeiten"
|
||||||
Command="{Binding EditGroupCommand}"/>
|
Command="{Binding EditGroupCommand}"/>
|
||||||
|
<MenuItem Header="Ins nächste Schuljahr übernehmen …"
|
||||||
|
Command="{Binding RollOverGroupCommand}"/>
|
||||||
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}"
|
<MenuItem Header="{Binding SelectedGroup.ArchiveActionLabel}"
|
||||||
Command="{Binding ToggleArchiveCommand}"/>
|
Command="{Binding ToggleArchiveCommand}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public partial class GroupListView : UserControl
|
|||||||
{
|
{
|
||||||
vm.OnAddGroup = ShowAddGroupDialog;
|
vm.OnAddGroup = ShowAddGroupDialog;
|
||||||
vm.OnEditGroup = ShowEditGroupDialog;
|
vm.OnEditGroup = ShowEditGroupDialog;
|
||||||
|
vm.OnRollOverGroup = ShowGroupRolloverDialog;
|
||||||
vm.OnConfirmDelete = ShowDeleteGroupDialog;
|
vm.OnConfirmDelete = ShowDeleteGroupDialog;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,23 @@ public partial class GroupListView : UserControl
|
|||||||
await dialog.ShowDialog<bool>(owner);
|
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)
|
private async Task<bool> ShowDeleteGroupDialog(GroupListItem group)
|
||||||
{
|
{
|
||||||
var dialog = new DeleteGroupDialog { DataContext = group.DisplayName };
|
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,*">
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||||
|
|
||||||
<Button Grid.Row="0" Content="+ Sitzung" Command="{Binding AddSessionCommand}"
|
<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">
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="10,0,10,6">
|
||||||
<Button Content="Schnell Mitarbeit" Command="{Binding QuickInputCommand}"
|
<Button Content="Schnell Mitarbeit" Command="{Binding QuickInputCommand}"
|
||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Anw./HA" Command="{Binding StatusQuickInputCommand}"/>
|
<Button Content="Anw./HA" Command="{Binding StatusQuickInputCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Row="2" Content="Ø Mitarbeitsnote" Command="{Binding ComputeGradeCommand}"
|
<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}"
|
<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"
|
<ListBox Grid.Row="4"
|
||||||
ItemsSource="{Binding Sessions}"
|
ItemsSource="{Binding Sessions}"
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
BorderThickness="0,0,0,1"
|
BorderThickness="0,0,0,1"
|
||||||
Padding="12,8">
|
Padding="12,8">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" MaxHeight="200">
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" MaxHeight="200">
|
||||||
<ItemsControl ItemsSource="{Binding CompetencyTagGroups}">
|
<ItemsControl ItemsSource="{Binding CompetencyTagGroups}" IsEnabled="{Binding !IsReadOnly}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate DataType="vm:CompetencyTagGroup">
|
<DataTemplate DataType="vm:CompetencyTagGroup">
|
||||||
<StackPanel Spacing="4" Margin="0,0,0,10">
|
<StackPanel Spacing="4" Margin="0,0,0,10">
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public partial class ParticipationTabView : UserControl
|
|||||||
{
|
{
|
||||||
Header = $"{aspect.Label} [{AspectShortcut(i)}]",
|
Header = $"{aspect.Label} [{AspectShortcut(i)}]",
|
||||||
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
|
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",
|
Header = "HA",
|
||||||
Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
|
Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
|
||||||
CellTemplate = BuildHomeworkCellTemplate(),
|
CellTemplate = BuildHomeworkCellTemplate(_vm.IsReadOnly),
|
||||||
});
|
});
|
||||||
grid.Columns.Add(new DataGridTemplateColumn
|
grid.Columns.Add(new DataGridTemplateColumn
|
||||||
{
|
{
|
||||||
Header = "Anwesenheit",
|
Header = "Anwesenheit",
|
||||||
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
|
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
|
||||||
CellTemplate = BuildAttendanceCellTemplate(),
|
CellTemplate = BuildAttendanceCellTemplate(_vm.IsReadOnly),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Kompetenz-Spalten (opt-in)
|
// Kompetenz-Spalten (opt-in)
|
||||||
@@ -85,13 +85,13 @@ public partial class ParticipationTabView : UserControl
|
|||||||
{
|
{
|
||||||
Header = code,
|
Header = code,
|
||||||
Width = new DataGridLength(80, DataGridLengthUnitType.Pixel),
|
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, _) =>
|
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
|
||||||
{
|
{
|
||||||
@@ -119,6 +119,7 @@ public partial class ParticipationTabView : UserControl
|
|||||||
Padding = new Avalonia.Thickness(5, 1),
|
Padding = new Avalonia.Thickness(5, 1),
|
||||||
FontSize = 11,
|
FontSize = 11,
|
||||||
Opacity = cell.Value == val ? 1.0 : 0.3,
|
Opacity = cell.Value == val ? 1.0 : 0.3,
|
||||||
|
IsEnabled = !isReadOnly,
|
||||||
};
|
};
|
||||||
var capturedVal = val;
|
var capturedVal = val;
|
||||||
btn.Click += (_, _) => cell.SetValue(capturedVal);
|
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, _) =>
|
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
|
||||||
{
|
{
|
||||||
@@ -147,6 +148,7 @@ public partial class ParticipationTabView : UserControl
|
|||||||
Height = 26,
|
Height = 26,
|
||||||
Padding = new Avalonia.Thickness(3, 1),
|
Padding = new Avalonia.Thickness(3, 1),
|
||||||
Command = row.ToggleHomeworkCommand,
|
Command = row.ToggleHomeworkCommand,
|
||||||
|
IsEnabled = !isReadOnly,
|
||||||
};
|
};
|
||||||
void RefreshHomework()
|
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, _) =>
|
return new FuncDataTemplate<ParticipationStudentRow>((row, _) =>
|
||||||
{
|
{
|
||||||
@@ -193,6 +195,7 @@ public partial class ParticipationTabView : UserControl
|
|||||||
Height = 26,
|
Height = 26,
|
||||||
Padding = new Avalonia.Thickness(3, 1),
|
Padding = new Avalonia.Thickness(3, 1),
|
||||||
Command = row.CycleAttendanceCommand,
|
Command = row.CycleAttendanceCommand,
|
||||||
|
IsEnabled = !isReadOnly,
|
||||||
};
|
};
|
||||||
void RefreshAttendance()
|
void RefreshAttendance()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
<TextBlock Grid.Column="0" Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
|
<TextBlock Grid.Column="0" Text="Unterrichtseinheiten" FontSize="14" FontWeight="SemiBold"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
<Button Content="+ Einheit" Command="{Binding AddUnitCommand}"/>
|
<Button Content="+ Einheit" Command="{Binding AddUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}"/>
|
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
|
<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>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -62,15 +62,16 @@
|
|||||||
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
|
<TextBlock Text="{Binding SelectedUnitTitleSuffix}" FontSize="14" FontWeight="SemiBold"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
<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}"
|
<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."/>
|
ToolTip.Tip="Stunden für alle Termine aus dem Stundenplan im gewählten Zeitraum anlegen."/>
|
||||||
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
|
<Button Content="Anzeigen" Command="{Binding ShowLessonCommand}"
|
||||||
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
ToolTip.Tip="Verlaufsplan schreibgeschützt und größer anzeigen — zum Mitnehmen in den Unterricht."/>
|
||||||
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}"/>
|
<Button Content="Bearbeiten" Command="{Binding EditLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}"/>
|
<Button Content="Verschieben" Command="{Binding MoveLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}"/>
|
<Button Content="Status → Durchgeführt" Command="{Binding AdvanceLessonStatusCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}"/>
|
<Button Content="Löschen" Command="{Binding DeleteLessonCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
|
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"
|
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
|
||||||
x:Class="LehrerApp.Desktop.Views.Settings.SettingsView"
|
x:Class="LehrerApp.Desktop.Views.Settings.SettingsView"
|
||||||
x:DataType="vm:SettingsViewModel">
|
x:DataType="vm:SettingsViewModel">
|
||||||
@@ -270,6 +271,86 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</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 -->
|
<!-- Tab: Notenschema -->
|
||||||
<ContentPage Header="Notenschema">
|
<ContentPage Header="Notenschema">
|
||||||
<ContentPage.Resources>
|
<ContentPage.Resources>
|
||||||
|
|||||||
@@ -91,4 +91,27 @@ public partial class SettingsView : UserControl
|
|||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner is not null) await dialog.ShowDialog(owner);
|
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"
|
<Button Grid.Column="4" Content="×" Padding="6,2"
|
||||||
Command="{Binding RemoveCommand}" FontSize="14"/>
|
Command="{Binding RemoveCommand}" FontSize="14"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<TextBox Text="{Binding LetterSalutation}"
|
||||||
|
PlaceholderText="Briefanrede, z.B. Sehr geehrte Frau Mustermann,"
|
||||||
|
FontSize="12"/>
|
||||||
<!-- Zeile 2: Telefon + E-Mail -->
|
<!-- Zeile 2: Telefon + E-Mail -->
|
||||||
<Grid ColumnDefinitions="*,10,*">
|
<Grid ColumnDefinitions="*,10,*">
|
||||||
<TextBox Grid.Column="0" Text="{Binding Phone}"
|
<TextBox Grid.Column="0" Text="{Binding Phone}"
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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,*">
|
<Grid ColumnDefinitions="*,10,*">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
<TextBlock Text="Telefon" FontSize="12" Opacity="0.7"/>
|
<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}"
|
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
BorderThickness="0,0,0,1">
|
BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<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}">
|
<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="0" Text="{Binding EditFirstName}" PlaceholderText="Vorname"/>
|
||||||
<TextBox Grid.Column="2" Text="{Binding EditLastName}" PlaceholderText="Nachname"/>
|
<TextBox Grid.Column="2" Text="{Binding EditLastName}" PlaceholderText="Nachname"/>
|
||||||
|
<ComboBox Grid.Column="4" ItemsSource="{Binding GenderOptions}"
|
||||||
|
SelectedItem="{Binding EditGender}" PlaceholderText="Geschlecht"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
<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}"
|
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
|
||||||
IsVisible="{Binding !IsEditing}"/>
|
IsVisible="{Binding !IsEditing}"/>
|
||||||
|
<Button Content="Schüler verwalten" Command="{Binding ManageStudentCommand}"
|
||||||
|
IsVisible="{Binding !IsEditing}"/>
|
||||||
<Button Content="Speichern" Command="{Binding SaveEditCommand}"
|
<Button Content="Speichern" Command="{Binding SaveEditCommand}"
|
||||||
IsVisible="{Binding IsEditing}"/>
|
IsVisible="{Binding IsEditing}"/>
|
||||||
<Button Content="Abbrechen" Command="{Binding CancelEditCommand}"
|
<Button Content="Abbrechen" Command="{Binding CancelEditCommand}"
|
||||||
@@ -88,6 +101,9 @@
|
|||||||
CornerRadius="4" Padding="6,2" HorizontalAlignment="Left">
|
CornerRadius="4" Padding="6,2" HorizontalAlignment="Left">
|
||||||
<TextBlock Text="{Binding Relation}" FontSize="11" Opacity="0.7"/>
|
<TextBlock Text="{Binding Relation}" FontSize="11" Opacity="0.7"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
<TextBlock Text="{Binding LetterSalutation}" FontSize="11" Opacity="0.6"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding LetterSalutation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Column="1" Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public partial class StudentDetailView : UserControl
|
|||||||
vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
|
vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
|
||||||
vm.OnSaveExportFile = SaveExportFile;
|
vm.OnSaveExportFile = SaveExportFile;
|
||||||
vm.OnConductParentCall = ShowParentCallSessionDialog;
|
vm.OnConductParentCall = ShowParentCallSessionDialog;
|
||||||
|
vm.OnManageStudent = ShowManageStudentDialog;
|
||||||
|
vm.OnCreateLetter = ShowCreateLetterDialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Contact?> ShowContactDialog(Contact? contact)
|
private async Task<Contact?> ShowContactDialog(Contact? contact)
|
||||||
@@ -36,6 +38,32 @@ public partial class StudentDetailView : UserControl
|
|||||||
return saved ? vm.Result : null;
|
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)
|
private void ShowAddressViewer(ContactItem contact)
|
||||||
{
|
{
|
||||||
if (!contact.HasAddress) return;
|
if (!contact.HasAddress) return;
|
||||||
|
|||||||
@@ -19,13 +19,25 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<DockPanel Grid.Row="1">
|
<DockPanel Grid.Row="1">
|
||||||
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
|
<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}"
|
<DataGrid ItemsSource="{Binding Students}"
|
||||||
SelectedItem="{Binding SelectedStudent}"
|
SelectedItem="{Binding SelectedStudent}"
|
||||||
AutoGenerateColumns="False" IsReadOnly="True"
|
AutoGenerateColumns="False" IsReadOnly="True"
|
||||||
GridLinesVisibility="Horizontal"
|
GridLinesVisibility="Horizontal"
|
||||||
CanUserReorderColumns="False" Margin="16,4">
|
CanUserReorderColumns="False" Margin="16,4">
|
||||||
<DataGrid.Columns>
|
<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="Name" Binding="{Binding FullName}" Width="*"/>
|
||||||
<DataGridTextColumn Header="Geburtsdatum" Binding="{Binding DateOfBirth}" Width="130"/>
|
<DataGridTextColumn Header="Geburtsdatum" Binding="{Binding DateOfBirth}" Width="130"/>
|
||||||
</DataGrid.Columns>
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
|
||||||
|
|
||||||
### 7.1 Schüler
|
### 7.1 Schüler
|
||||||
- [ ] **7.1.1** Schüler löschen / auf inaktiv setzen inkl. Hinweis auf verknüpfte Daten.
|
- [x] **7.1.1** Schülerverwaltung im Schülerdetail: Deaktivieren blendet den Schüler aus der
|
||||||
- [ ] **7.1.2** Suche in der Schülerliste (Name, Gruppe) mit Sofortfilter.
|
normalen Schülerliste aus und bewahrt alle historischen Daten; deaktivierte Schüler können
|
||||||
- [ ] **7.1.3** Foto/Avatar je Schüler (optional, lokal gespeichert) für schnellere Zuordnung.
|
wieder aktiviert werden. Ein Verwaltungsdialog zeigt die Anzahl verknüpfter Gruppenzuordnungen,
|
||||||
- [ ] **7.1.4** Serienbrieffelder aus Kontaktdaten (Anrede, Adresse) für Elternbriefe.
|
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.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung.
|
||||||
|
|
||||||
### 7.2 Gruppen
|
### 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
|
abhängigen Datensätze (inkl. Zeugnisnoten, gruppenspezifischem Notenschema und
|
||||||
Mitarbeitsabschnitten). Dokumentation, Aufgaben und Zeiteinträge bleiben als historische
|
Mitarbeitsabschnitten). Dokumentation, Aufgaben und Zeiteinträge bleiben als historische
|
||||||
Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt.
|
Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt.
|
||||||
- [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft,
|
- [x] **7.2.2** Gruppe ins neue Schuljahr übernehmen — eigener Dialog im Verwaltungsmenü mit
|
||||||
neues `SchoolYear`, neue `GroupMembership`-Einträge.
|
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
|
- [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
|
Dialog mit `CalendarDatePicker`, aktuellem Datum als Vorbelegung und frei wählbarem
|
||||||
Austrittsdatum. Setzt `GroupMembership.LeftAt`; Eintrittsdatum, Niveau und historische
|
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
|
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
|
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.
|
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 Import
|
||||||
- [ ] **7.3.1** CSV-Import von Schülerlisten mit Spaltenzuordnung im Dialog.
|
- [ ] **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.3** PDF-Erzeugung (Bibliothek auswählen — z.B. QuestPDF) mit einheitlichem Layout.
|
||||||
- [ ] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
|
- [ ] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
|
||||||
Schülerdokumentation.
|
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).
|
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user