diff --git a/LehrerApp.Core/Interfaces/IAttachmentStorage.cs b/LehrerApp.Core/Interfaces/IAttachmentStorage.cs
new file mode 100644
index 0000000..d932107
--- /dev/null
+++ b/LehrerApp.Core/Interfaces/IAttachmentStorage.cs
@@ -0,0 +1,17 @@
+namespace LehrerApp.Core.Interfaces;
+
+///
+/// Dateianhänge an Dokumentationseinträgen (z.B. der versendete Elternbrief als PDF).
+/// Implementiert über den LiteDB-Dateispeicher — kein eigenes Dateisystem-Layout nötig.
+///
+public interface IAttachmentStorage
+{
+ /// Größte zulässige Anhangsgröße. Bewusst strikt begrenzt, damit die LiteDB-Datei
+ /// (und damit auch jedes Backup, 13.3.1) nicht durch große Anhänge aufgebläht wird.
+ const long MaxSizeBytes = 10 * 1024 * 1024;
+
+ /// Lädt den Inhalt hoch und gibt die Speicher-ID zurück (in zu speichern).
+ string Upload(string fileName, Stream content);
+ Stream? OpenRead(string storageId);
+ void Delete(string storageId);
+}
diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs
index da68346..b06a63c 100644
--- a/LehrerApp.Core/Interfaces/IRepositories.cs
+++ b/LehrerApp.Core/Interfaces/IRepositories.cs
@@ -89,8 +89,13 @@ public interface IDocumentationRepository
{
List GetByStudent(Guid studentId);
List GetByStudentAndType(Guid studentId, DocumentationType type);
+ /// Alle nicht gelöschten Einträge, über alle Schüler hinweg (z.B. für Dashboard-Auswertungen).
+ List GetAll();
void Save(Documentation doc);
+ /// Markiert den Eintrag als gelöscht, statt ihn hart zu entfernen (5.1.4).
void Delete(Guid id);
+ /// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
+ void HardDelete(Guid id);
}
public interface IWorkTaskRepository
{
diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs
index 9e8acf8..fef15ec 100644
--- a/LehrerApp.Core/Models/Workload.cs
+++ b/LehrerApp.Core/Models/Workload.cs
@@ -12,9 +12,17 @@ public class Documentation
public List Participants { get; set; } = [];
public AbsenceData? AbsenceData { get; set; }
public SupportData? SupportData { get; set; }
+ public ParentCallData? ParentCallData { get; set; }
+ public ParentLetterData? ParentLetterData { get; set; }
+ public List Attachments { get; set; } = [];
+ /// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
+ public List Tags { get; set; } = [];
public bool IsConfidential { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+ // Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
+ public bool IsDeleted { get; set; }
+ public DateTime? DeletedAt { get; set; }
}
public class AbsenceData
{
@@ -28,7 +36,43 @@ public class SupportData
public DateOnly? ReviewDate { get; set; }
public SupportStatus Status { get; set; } = SupportStatus.Active;
}
-public enum DocumentationType { Conversation, Incident, SupportPlan, Absence }
+/// Geplante Gesprächspunkte eines Elternanrufs, abgehakt in .
+public class ParentCallPoint
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Text { get; set; } = "";
+ public bool IsDone { get; set; }
+}
+///
+/// Elternanruf: Gesprächspunkte werden vorab geplant, dann im begleitenden Dialog während des
+/// Anrufs abgehakt und um Eindrücke/Ergänzungen zum Protokoll ergänzt.
+///
+public class ParentCallData
+{
+ public List Points { get; set; } = [];
+ public string Impressions { get; set; } = "";
+ public bool IsConducted { get; set; }
+ public DateOnly? ConductedDate { get; set; }
+}
+/// Elternbrief: Entwurf/Planung sowie Absende- und Rückmeldedaten.
+public class ParentLetterData
+{
+ public string DraftContent { get; set; } = "";
+ public DateOnly? SentDate { get; set; }
+ public bool ResponseReceived { get; set; }
+ public DateOnly? ResponseDate { get; set; }
+ public string ResponseNote { get; set; } = "";
+}
+/// Datei-Anhang eines Dokumentationseintrags, im LiteDB-Dateispeicher abgelegt.
+public class DocumentAttachment
+{
+ public string StorageId { get; set; } = "";
+ public string FileName { get; set; } = "";
+ public long SizeBytes { get; set; }
+ public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
+}
+// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
+public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
public enum SupportStatus { Active, Completed, Paused }
public class WorkTask
diff --git a/LehrerApp.Core/Services/AttendanceBalanceService.cs b/LehrerApp.Core/Services/AttendanceBalanceService.cs
new file mode 100644
index 0000000..2b894bb
--- /dev/null
+++ b/LehrerApp.Core/Services/AttendanceBalanceService.cs
@@ -0,0 +1,41 @@
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Core.Services;
+
+public record AttendanceBalance(
+ int TotalChecked, int Present, int Excused, int Unexcused,
+ int SchoolEvent, int ExcusePending, double AbsenceRatePercent)
+{
+ public bool ExceedsThreshold => AbsenceRatePercent > AttendanceBalanceService.WarningThresholdPercent;
+}
+
+///
+/// Fehlzeitenbilanz (5.2.2/5.2.3) — reine Auswertung der bereits im Mitarbeit-Feature
+/// erfassten -Werte, keine zweite Erfassung.
+///
+public class AttendanceBalanceService
+{
+ public const double WarningThresholdPercent = 20.0;
+
+ public AttendanceBalance Calculate(IEnumerable<(DateOnly Date, AttendanceStatus? Status)> entries,
+ DateOnly from, DateOnly to)
+ {
+ var relevant = entries
+ .Where(e => e.Status.HasValue && e.Date >= from && e.Date <= to)
+ .Select(e => e.Status!.Value)
+ .ToList();
+
+ var present = relevant.Count(s => s == AttendanceStatus.Present);
+ var excused = relevant.Count(s => s == AttendanceStatus.Excused);
+ var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused or AttendanceStatus.Truant);
+ var schoolEvent = relevant.Count(s => s == AttendanceStatus.OtherSchoolEvent);
+ var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
+ var total = relevant.Count;
+
+ // Schulisch veranlasste Abwesenheit (Exkursion o.ä.) zählt nicht als Fehlzeit des Schülers.
+ var absenceCount = excused + unexcused + pending;
+ var rate = total == 0 ? 0.0 : Math.Round(100.0 * absenceCount / total, 1);
+
+ return new AttendanceBalance(total, present, excused, unexcused, schoolEvent, pending, rate);
+ }
+}
diff --git a/LehrerApp.Core/Services/PersonalDataExportService.cs b/LehrerApp.Core/Services/PersonalDataExportService.cs
new file mode 100644
index 0000000..9dea1cf
--- /dev/null
+++ b/LehrerApp.Core/Services/PersonalDataExportService.cs
@@ -0,0 +1,56 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using LehrerApp.Core.Interfaces;
+
+namespace LehrerApp.Core.Services;
+
+///
+/// Datenauskunft für einen Schüler (5.4.3, Art. 15 DSGVO) — sammelt alle über die Person
+/// gespeicherten Daten aus den einzelnen Repositories zu einem Export.
+///
+/// Enthält bewusst auch als vertraulich markierte Dokumentationseinträge: das
+/// Vertraulich-Kennzeichen in dieser App blendet Inhalte in der laufenden Ansicht aus,
+/// ist aber keine rechtliche Ausnahme vom Auskunftsanspruch der betroffenen Person selbst.
+/// Ob im Einzelfall doch eine Ausnahme greift (z.B. schutzwürdige Belange Dritter nach
+/// Landes-Schulrecht), muss die verantwortliche Lehrkraft/Schule selbst prüfen.
+///
+public class PersonalDataExportService(
+ IStudentRepository students,
+ IGroupRepository groups,
+ IGroupMembershipRepository memberships,
+ IGradeRepository grades,
+ IExamResultRepository examResults,
+ IParticipationRepository participation,
+ IDocumentationRepository documentation)
+{
+ public string ExportAsJson(Guid studentId)
+ {
+ var student = students.GetById(studentId)
+ ?? throw new InvalidOperationException("Schüler nicht gefunden.");
+
+ var studentMemberships = memberships.GetByStudent(studentId);
+ var studentGroups = studentMemberships
+ .Select(m => groups.GetById(m.GroupId))
+ .Where(g => g is not null)
+ .ToList();
+
+ var dto = new
+ {
+ ExportedAt = DateTime.UtcNow,
+ Student = student,
+ Gruppenzuordnungen = studentMemberships,
+ Noten = studentGroups.SelectMany(g => grades.GetByStudentAndGroup(studentId, g!.Id)).ToList(),
+ Klausurergebnisse = examResults.GetByStudent(studentId),
+ Mitarbeit = participation.GetByStudent(studentId),
+ Dokumentation = documentation.GetByStudent(studentId),
+ };
+
+ return JsonSerializer.Serialize(dto, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ // Ohne diese Option würden Umlaute als \uXXXX escaped — schlecht lesbar für den
+ // eigentlichen Zweck dieses Exports (Auskunft an die betroffene Person).
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ });
+ }
+}
diff --git a/LehrerApp.Core/Services/PrivacySettingsService.cs b/LehrerApp.Core/Services/PrivacySettingsService.cs
new file mode 100644
index 0000000..19354d8
--- /dev/null
+++ b/LehrerApp.Core/Services/PrivacySettingsService.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+
+namespace LehrerApp.Core.Services;
+
+internal class PrivacySettingsConfig
+{
+ public int RetentionYears { get; set; } = 3;
+}
+
+///
+/// Löschfristen für Dokumentationseinträge (5.4.2). Löscht nichts automatisch — schlägt
+/// abgelaufene Einträge nur zur manuellen Prüfung/Löschung vor.
+///
+public class PrivacySettingsService
+{
+ private readonly string _configPath;
+ private PrivacySettingsConfig _config;
+
+ public int RetentionYears => _config.RetentionYears;
+
+ public PrivacySettingsService(string appDataPath)
+ {
+ _configPath = Path.Combine(appDataPath, "privacy.json");
+ _config = Load();
+ }
+
+ public void SetRetentionYears(int years)
+ {
+ _config.RetentionYears = Math.Max(1, years);
+ File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
+ }
+
+ public DateTime RetentionCutoff(DateTime? now = null) =>
+ (now ?? DateTime.UtcNow).AddYears(-RetentionYears);
+
+ private PrivacySettingsConfig Load()
+ {
+ try
+ {
+ if (File.Exists(_configPath))
+ return JsonSerializer.Deserialize(File.ReadAllText(_configPath))
+ ?? new PrivacySettingsConfig();
+ }
+ catch { /* beschädigte Konfiguration -> Standardwert */ }
+ return new PrivacySettingsConfig();
+ }
+}
diff --git a/LehrerApp.Data.Tests/AssemblyInfo.cs b/LehrerApp.Data.Tests/AssemblyInfo.cs
new file mode 100644
index 0000000..bc0aa08
--- /dev/null
+++ b/LehrerApp.Data.Tests/AssemblyInfo.cs
@@ -0,0 +1,9 @@
+using Xunit;
+
+// LiteDB nutzt einen statischen, geteilten BsonMapper.Global für die Reflection-basierte
+// Index-Auflösung (EnsureIndex mit Lambda-Ausdrücken). Bei paralleler Testausführung über
+// mehrere Testklassen hinweg (xUnit-Standard) konkurrieren mehrere Threads beim erstmaligen
+// Aufbau der Typ-Metadaten für verschiedene Modelle — das führt zu sporadischen
+// "Member X not found on BsonMapper"-Fehlern, die mit dem eigentlichen Testinhalt nichts zu
+// tun haben. Tests in diesem Projekt laufen deshalb sequenziell.
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/LehrerApp.Data.Tests/LiteAttachmentStorageTests.cs b/LehrerApp.Data.Tests/LiteAttachmentStorageTests.cs
new file mode 100644
index 0000000..9da0bd3
--- /dev/null
+++ b/LehrerApp.Data.Tests/LiteAttachmentStorageTests.cs
@@ -0,0 +1,59 @@
+using Xunit;
+
+namespace LehrerApp.Data.Tests;
+
+public sealed class LiteAttachmentStorageTests
+{
+ private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
+
+ [Fact]
+ public void Upload_OpenRead_RoundtripLiefertDenGespeichertenInhalt()
+ {
+ using var db = NewInMemoryContext();
+ var storage = new LiteAttachmentStorage(db);
+ var bytes = "Elternbrief-Inhalt äöü"u8.ToArray();
+
+ string id;
+ using (var ms = new MemoryStream(bytes))
+ id = storage.Upload("Brief.pdf", ms);
+
+ using var result = storage.OpenRead(id);
+ Assert.NotNull(result);
+ using var reader = new MemoryStream();
+ result!.CopyTo(reader);
+ Assert.Equal(bytes, reader.ToArray());
+ }
+
+ [Fact]
+ public void Upload_GroessereDateiAlsDasLimit_WirftException()
+ {
+ using var db = NewInMemoryContext();
+ var storage = new LiteAttachmentStorage(db);
+ using var ms = new MemoryStream(new byte[LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes + 1]);
+
+ Assert.Throws(() => storage.Upload("zu-gross.bin", ms));
+ }
+
+ [Fact]
+ public void OpenRead_UnbekannteId_GibtNullZurueck()
+ {
+ using var db = NewInMemoryContext();
+ var storage = new LiteAttachmentStorage(db);
+
+ Assert.Null(storage.OpenRead("fehlt"));
+ }
+
+ [Fact]
+ public void Delete_EntferntDenAnhangEndgueltig()
+ {
+ using var db = NewInMemoryContext();
+ var storage = new LiteAttachmentStorage(db);
+ string id;
+ using (var ms = new MemoryStream([1, 2, 3]))
+ id = storage.Upload("a.bin", ms);
+
+ storage.Delete(id);
+
+ Assert.Null(storage.OpenRead(id));
+ }
+}
diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs
index ebbf42a..d5c1af2 100644
--- a/LehrerApp.Data.Tests/RepositoryTests.cs
+++ b/LehrerApp.Data.Tests/RepositoryTests.cs
@@ -331,4 +331,73 @@ public sealed class RepositoryTests
Assert.Throws(() =>
db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = sessionId, StudentId = studentId }));
}
+
+ // ── DocumentationRepository ───────────────────────────────────────────────
+
+ [Fact]
+ public void DocumentationRepository_Delete_MarkiertNurAlsGeloescht()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new DocumentationRepository(db);
+ var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Gespräch" };
+ repo.Save(doc);
+
+ repo.Delete(doc.Id);
+
+ Assert.Empty(repo.GetByStudent(doc.StudentId));
+ var raw = db.Documentation.FindById(doc.Id);
+ Assert.NotNull(raw);
+ Assert.True(raw.IsDeleted);
+ Assert.NotNull(raw.DeletedAt);
+ }
+
+ [Fact]
+ public void DocumentationRepository_HardDelete_EntferntDenEintragEndgueltig()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new DocumentationRepository(db);
+ var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Vorkommnis" };
+ repo.Save(doc);
+
+ repo.HardDelete(doc.Id);
+
+ Assert.Null(db.Documentation.FindById(doc.Id));
+ }
+
+ [Fact]
+ public void DocumentationRepository_GetAll_FiltertGeloeschteEintraegeUndUmfasstAlleSchueler()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new DocumentationRepository(db);
+ var visible = new Documentation { StudentId = Guid.NewGuid(), Title = "Sichtbar" };
+ var deleted = new Documentation { StudentId = Guid.NewGuid(), Title = "Gelöscht" };
+ repo.Save(visible);
+ repo.Save(deleted);
+ repo.Delete(deleted.Id);
+
+ var all = repo.GetAll();
+
+ Assert.Single(all);
+ Assert.Equal(visible.Id, all[0].Id);
+ }
+
+ [Fact]
+ public void DocumentationRepository_HardDelete_EntferntAuchDieAnhaenge()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new DocumentationRepository(db);
+ var storage = new LiteAttachmentStorage(db);
+ string attachmentId;
+ using (var ms = new MemoryStream([1, 2, 3]))
+ attachmentId = storage.Upload("brief.pdf", ms);
+
+ var doc = new Documentation { StudentId = Guid.NewGuid(), Title = "Elternbrief" };
+ doc.Attachments.Add(new DocumentAttachment { StorageId = attachmentId, FileName = "brief.pdf", SizeBytes = 3 });
+ repo.Save(doc);
+
+ repo.HardDelete(doc.Id);
+
+ Assert.Null(db.Documentation.FindById(doc.Id));
+ Assert.Null(storage.OpenRead(attachmentId));
+ }
}
diff --git a/LehrerApp.Data/LiteAttachmentStorage.cs b/LehrerApp.Data/LiteAttachmentStorage.cs
new file mode 100644
index 0000000..2e6ea8c
--- /dev/null
+++ b/LehrerApp.Data/LiteAttachmentStorage.cs
@@ -0,0 +1,22 @@
+using LehrerApp.Core.Interfaces;
+
+namespace LehrerApp.Data;
+
+public class LiteAttachmentStorage(LiteDbContext db) : IAttachmentStorage
+{
+ public string Upload(string fileName, Stream content)
+ {
+ if (content.Length > IAttachmentStorage.MaxSizeBytes)
+ throw new InvalidOperationException(
+ $"Datei ist größer als {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB.");
+
+ var id = Guid.NewGuid().ToString("N");
+ db.Attachments.Upload(id, fileName, content);
+ return id;
+ }
+
+ public Stream? OpenRead(string storageId) =>
+ db.Attachments.Exists(storageId) ? db.Attachments.OpenRead(storageId) : null;
+
+ public void Delete(string storageId) => db.Attachments.Delete(storageId);
+}
diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs
index 46df789..e6041fc 100644
--- a/LehrerApp.Data/LiteDbContext.cs
+++ b/LehrerApp.Data/LiteDbContext.cs
@@ -46,6 +46,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection Units => _db.GetCollection("units");
public ILiteCollection Lessons => _db.GetCollection("lessons");
public ILiteCollection Documentation => _db.GetCollection("documentation");
+ public ILiteStorage Attachments => _db.GetStorage("attachments", "attachments_chunks");
public ILiteCollection Tasks => _db.GetCollection("tasks");
public ILiteCollection TimeEntries => _db.GetCollection("time_entries");
public ILiteCollection ParticipationSessions => _db.GetCollection("participation_sessions");
diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs
index ef333c0..9f72e71 100644
--- a/LehrerApp.Data/Repositories/AllRepositories.cs
+++ b/LehrerApp.Data/Repositories/AllRepositories.cs
@@ -190,12 +190,28 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository
{
public List GetByStudent(Guid id) =>
- db.Documentation.Find(d => d.StudentId == id).OrderByDescending(d => d.Date).ToList();
+ db.Documentation.Find(d => d.StudentId == id && !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
public List GetByStudentAndType(Guid sid, DocumentationType type) =>
- db.Documentation.Find(d => d.StudentId == sid && d.Type == type)
+ db.Documentation.Find(d => d.StudentId == sid && d.Type == type && !d.IsDeleted)
.OrderByDescending(d => d.Date).ToList();
+ public List GetAll() =>
+ db.Documentation.Find(d => !d.IsDeleted).OrderByDescending(d => d.Date).ToList();
public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); }
- public void Delete(Guid id) => db.Documentation.Delete(id);
+ public void Delete(Guid id)
+ {
+ var doc = db.Documentation.FindById(id);
+ if (doc is null) return;
+ doc.IsDeleted = true;
+ doc.DeletedAt = DateTime.UtcNow;
+ db.Documentation.Update(doc);
+ }
+ public void HardDelete(Guid id)
+ {
+ var doc = db.Documentation.FindById(id);
+ if (doc is not null)
+ foreach (var attachment in doc.Attachments) db.Attachments.Delete(attachment.StorageId);
+ db.Documentation.Delete(id);
+ }
}
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
diff --git a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs
new file mode 100644
index 0000000..55bb88a
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs
@@ -0,0 +1,206 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.ViewModels.Students;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class DocumentationDialogViewModelTests
+{
+ [Fact]
+ public void Save_ContentIstNull_WirftNichtUndSpeichertLeerenText()
+ {
+ // Regression: Avalonias TextBox.Text kann beim vollständigen Leeren des Felds über das
+ // Zwei-Wege-Binding null statt "" liefern (beobachtet bei einem Fehlzeit-Eintrag ohne
+ // Beschreibung) — Content.Trim() ohne Null-Schutz warf eine NullReferenceException.
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage())
+ {
+ Title = "Fehlt am Montag", TypeName = "Fehlzeit", Content = null!,
+ };
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.NotNull(vm.Result);
+ Assert.Equal("", vm.Result!.Content);
+ }
+
+ [Fact]
+ public void Save_ElternbriefFelderSindNull_WirftNichtUndSpeichertLeerenText()
+ {
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage())
+ {
+ Title = "Brief", TypeName = "Elternbrief",
+ LetterDraftContent = null!, LetterResponseNote = null!,
+ };
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.NotNull(vm.Result);
+ Assert.Equal("", vm.Result!.ParentLetterData!.DraftContent);
+ Assert.Equal("", vm.Result.ParentLetterData.ResponseNote);
+ }
+
+ [Fact]
+ public void AddTag_NewTagIstNull_WirftNichtUndFuegtNichtsHinzu()
+ {
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage())
+ {
+ NewTag = null!,
+ };
+
+ vm.AddTagCommand.Execute(null);
+
+ Assert.Empty(vm.Tags);
+ }
+
+ [Fact]
+ public void Save_UebernimmtHinzugefuegteLabelsUndVerhindertDuplikate()
+ {
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage())
+ {
+ Title = "Vorfall im Unterricht", TypeName = "Vorkommnis",
+ };
+ vm.NewTag = "Kritisch";
+ vm.AddTagCommand.Execute(null);
+ vm.NewTag = "Kritisch"; // Duplikat, soll ignoriert werden
+ vm.AddTagCommand.Execute(null);
+ vm.NewTag = "Mit JGL abklären";
+ vm.AddTagCommand.Execute(null);
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.Equal(["Kritisch", "Mit JGL abklären"], vm.Result!.Tags);
+ }
+
+ [Fact]
+ public void RemoveTag_EntferntDasLabelWiederAusDerListe()
+ {
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage());
+ vm.NewTag = "Beobachten";
+ vm.AddTagCommand.Execute(null);
+
+ vm.RemoveTagCommand.Execute("Beobachten");
+
+ Assert.Empty(vm.Tags);
+ }
+
+ [Fact]
+ public void Save_Elternanruf_UebernimmtGeplanteGespraechspunkte()
+ {
+ var studentId = Guid.NewGuid();
+ var vm = new DocumentationDialogViewModel(studentId, null, new FakeAttachmentStorage())
+ {
+ Title = "Anruf wegen Hausaufgaben",
+ TypeName = "Elternanruf",
+ };
+ vm.NewCallPoint = "Hausaufgabensituation ansprechen";
+ vm.AddCallPointCommand.Execute(null);
+ vm.NewCallPoint = "Nächste Schritte vereinbaren";
+ vm.AddCallPointCommand.Execute(null);
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.NotNull(vm.Result);
+ Assert.Equal(DocumentationType.ParentCall, vm.Result!.Type);
+ Assert.Equal(2, vm.Result.ParentCallData!.Points.Count);
+ Assert.All(vm.Result.ParentCallData.Points, p => Assert.False(p.IsDone));
+ Assert.False(vm.Result.ParentCallData.IsConducted);
+ }
+
+ [Fact]
+ public void Save_ElternanrufBearbeiten_BehaeltAbgehakteStatusFuerUnveraenderteePunkte()
+ {
+ var studentId = Guid.NewGuid();
+ var existing = new Documentation
+ {
+ StudentId = studentId,
+ Type = DocumentationType.ParentCall,
+ Title = "Anruf",
+ ParentCallData = new ParentCallData
+ {
+ Points = [new ParentCallPoint { Text = "Punkt A", IsDone = true }],
+ IsConducted = true,
+ ConductedDate = new DateOnly(2026, 1, 10),
+ Impressions = "Gut verlaufen",
+ },
+ };
+
+ var vm = new DocumentationDialogViewModel(studentId, existing, new FakeAttachmentStorage());
+ vm.NewCallPoint = "Punkt B";
+ vm.AddCallPointCommand.Execute(null);
+
+ vm.SaveCommand.Execute(null);
+
+ var points = vm.Result!.ParentCallData!.Points;
+ Assert.True(points.Single(p => p.Text == "Punkt A").IsDone);
+ Assert.False(points.Single(p => p.Text == "Punkt B").IsDone);
+ Assert.True(vm.Result.ParentCallData.IsConducted);
+ Assert.Equal("Gut verlaufen", vm.Result.ParentCallData.Impressions);
+ }
+
+ [Fact]
+ public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
+ {
+ var studentId = Guid.NewGuid();
+ var vm = new DocumentationDialogViewModel(studentId, null, new FakeAttachmentStorage())
+ {
+ Title = "Brief wegen Verhalten",
+ TypeName = "Elternbrief",
+ LetterDraftContent = "Sehr geehrte...",
+ LetterSentDateText = "05.09.2025",
+ LetterResponseReceived = true,
+ LetterResponseDateText = "12.09.2025",
+ LetterResponseNote = "Termin vereinbart",
+ };
+
+ vm.SaveCommand.Execute(null);
+
+ var letter = vm.Result!.ParentLetterData!;
+ Assert.Equal(new DateOnly(2025, 9, 5), letter.SentDate);
+ Assert.True(letter.ResponseReceived);
+ Assert.Equal(new DateOnly(2025, 9, 12), letter.ResponseDate);
+ Assert.Equal("Termin vereinbart", letter.ResponseNote);
+ }
+
+ [Fact]
+ public void Save_UngueltigesDatumBeimElternbrief_SetztFehlerUndSpeichertNicht()
+ {
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage())
+ {
+ Title = "Brief", TypeName = "Elternbrief", LetterSentDateText = "nicht-valide",
+ };
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.Null(vm.Result);
+ Assert.NotEqual("", vm.LetterSentDateError);
+ }
+
+ [Fact]
+ public void AddAttachment_GroessereDateiAlsDasLimit_SetztFehlerUndFuegtNichtHinzu()
+ {
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage());
+ using var huge = new MemoryStream(new byte[LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes + 1]);
+
+ vm.AddAttachment("gross.bin", huge);
+
+ Assert.Empty(vm.Attachments);
+ Assert.NotEqual("", vm.AttachmentError);
+ }
+
+ [Fact]
+ public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge()
+ {
+ var storage = new FakeAttachmentStorage();
+ var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, storage)
+ {
+ Title = "Brief", TypeName = "Elternbrief",
+ };
+ using var content = new MemoryStream([1, 2, 3]);
+ vm.AddAttachment("brief.pdf", content);
+ var storageId = vm.Attachments.Single().StorageId;
+
+ vm.DiscardUnsavedAttachments();
+
+ Assert.Null(storage.OpenRead(storageId));
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/DocumentationTagDisplayTests.cs b/LehrerApp.Desktop.Tests/DocumentationTagDisplayTests.cs
new file mode 100644
index 0000000..3fa26a5
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/DocumentationTagDisplayTests.cs
@@ -0,0 +1,23 @@
+using LehrerApp.Desktop.ViewModels.Students;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class DocumentationTagDisplayTests
+{
+ [Theory]
+ [InlineData("Kritisch", "#E53935")]
+ [InlineData("Erledigt", "#43A047")]
+ [InlineData("Beobachten", "#1E88E5")]
+ [InlineData("Mit JGL abklären", "#FB8C00")]
+ public void ColorHex_BekanntesLabel_GibtErwarteteFarbeZurueck(string tag, string expectedHex)
+ {
+ Assert.Equal(expectedHex, DocumentationTagDisplay.ColorHex(tag));
+ }
+
+ [Fact]
+ public void ColorHex_UnbekanntesLabel_GibtNeutraleFarbeZurueck()
+ {
+ Assert.Equal("#757575", DocumentationTagDisplay.ColorHex("Eigenes freies Label"));
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs
index d6883ee..e60fdb3 100644
--- a/LehrerApp.Desktop.Tests/Fakes.cs
+++ b/LehrerApp.Desktop.Tests/Fakes.cs
@@ -110,6 +110,44 @@ public class FakeSchemes : IGradingSchemeRepository
public void Delete(Guid id) { }
}
+public class FakeGroups(List all) : IGroupRepository
+{
+ public LearningGroup? GetById(Guid id) => all.FirstOrDefault(g => g.Id == id);
+ public List GetAll(bool includeInactive = false) => all;
+ public List GetBySchoolYear(string schoolYear, bool includeInactive = false) => all;
+ public void Save(LearningGroup group) { }
+ public void Delete(Guid id) { }
+}
+
+public class FakeDocumentation : IDocumentationRepository
+{
+ private readonly List _all = [];
+ public void Add(Documentation d) => _all.Add(d);
+ public List GetByStudent(Guid studentId) =>
+ _all.Where(d => d.StudentId == studentId && !d.IsDeleted).ToList();
+ public List GetByStudentAndType(Guid studentId, DocumentationType type) =>
+ _all.Where(d => d.StudentId == studentId && d.Type == type && !d.IsDeleted).ToList();
+ public List GetAll() => _all.Where(d => !d.IsDeleted).ToList();
+ public void Save(Documentation doc) { _all.RemoveAll(d => d.Id == doc.Id); _all.Add(doc); }
+ public void Delete(Guid id) { var d = _all.FirstOrDefault(x => x.Id == id); if (d is not null) d.IsDeleted = true; }
+ public void HardDelete(Guid id) => _all.RemoveAll(d => d.Id == id);
+}
+
+public class FakeAttachmentStorage : IAttachmentStorage
+{
+ private readonly Dictionary _blobs = [];
+ public string Upload(string fileName, Stream content)
+ {
+ using var ms = new MemoryStream();
+ content.CopyTo(ms);
+ var id = Guid.NewGuid().ToString("N");
+ _blobs[id] = ms.ToArray();
+ return id;
+ }
+ public Stream? OpenRead(string storageId) => _blobs.TryGetValue(storageId, out var b) ? new MemoryStream(b) : null;
+ public void Delete(string storageId) => _blobs.Remove(storageId);
+}
+
public class FakeReportGrades : IReportGradeRepository
{
private readonly List _all = [];
diff --git a/LehrerApp.Desktop.Tests/ParentCallSessionViewModelTests.cs b/LehrerApp.Desktop.Tests/ParentCallSessionViewModelTests.cs
new file mode 100644
index 0000000..d6b7780
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/ParentCallSessionViewModelTests.cs
@@ -0,0 +1,35 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.ViewModels.Students;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class ParentCallSessionViewModelTests
+{
+ [Fact]
+ public void SaveProtocol_UebernimmtAbgehakteStatusUndEindruecke()
+ {
+ var pointId = Guid.NewGuid();
+ var documentation = new Documentation
+ {
+ Type = DocumentationType.ParentCall,
+ ParentCallData = new ParentCallData
+ {
+ Points = [new ParentCallPoint { Id = pointId, Text = "Hausaufgaben ansprechen" }],
+ },
+ };
+ var vm = new ParentCallSessionViewModel(documentation, "Anna Beispiel")
+ {
+ Impressions = "Eltern reagierten verständnisvoll.",
+ };
+ vm.Points.Single().IsDone = true;
+
+ vm.SaveProtocol();
+
+ Assert.NotNull(vm.Result);
+ Assert.True(vm.Result!.ParentCallData!.IsConducted);
+ Assert.Equal(DateOnly.FromDateTime(DateTime.Today), vm.Result.ParentCallData.ConductedDate);
+ Assert.True(vm.Result.ParentCallData.Points.Single(p => p.Id == pointId).IsDone);
+ Assert.Equal("Eltern reagierten verständnisvoll.", vm.Result.ParentCallData.Impressions);
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/PersonalDataExportServiceTests.cs b/LehrerApp.Desktop.Tests/PersonalDataExportServiceTests.cs
new file mode 100644
index 0000000..9c43c1f
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/PersonalDataExportServiceTests.cs
@@ -0,0 +1,45 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class PersonalDataExportServiceTests
+{
+ [Fact]
+ public void ExportAsJson_EnthaeltNotenMitarbeitUndDokumentationDesSchuelers()
+ {
+ var studentId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ var student = new Student { Id = studentId, FirstName = "Anna", LastName = "Beispiel" };
+
+ var students = new FakeStudents([student]);
+ var groups = new FakeGroups([new LearningGroup { Id = groupId, Name = "Q1 Chemie" }]);
+ var memberships = new FakeMemberships([new GroupMembership { StudentId = studentId, GroupId = groupId }]);
+ var grades = new FakeGrades();
+ grades.Add(new Grade { StudentId = studentId, GroupId = groupId, Value = "2", Category = GradeCategory.Oral });
+ var results = new FakeResults();
+ var entries = new FakeEntries();
+ entries.Add(new ParticipationEntry { StudentId = studentId, SessionId = Guid.NewGuid() });
+ var documentation = new FakeDocumentation();
+ documentation.Add(new Documentation { StudentId = studentId, Title = "Elterngespräch" });
+
+ var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries, documentation);
+
+ var json = service.ExportAsJson(studentId);
+
+ Assert.Contains("Anna", json);
+ Assert.Contains("Elterngespräch", json);
+ Assert.Contains("\"Value\": \"2\"", json);
+ }
+
+ [Fact]
+ public void ExportAsJson_UnbekannterSchueler_WirftException()
+ {
+ var service = new PersonalDataExportService(
+ new FakeStudents([]), new FakeGroups([]), new FakeMemberships([]),
+ new FakeGrades(), new FakeResults(), new FakeEntries(), new FakeDocumentation());
+
+ Assert.Throws(() => service.ExportAsJson(Guid.NewGuid()));
+ }
+}
diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs
index 7808a45..549079e 100644
--- a/LehrerApp.Desktop/App.axaml.cs
+++ b/LehrerApp.Desktop/App.axaml.cs
@@ -65,9 +65,10 @@ public class App : Application
var gl = Services.GetRequiredService();
gl.OnNavigateToDetail = (id, tab) => main.NavigateToGroupDetail(id, tab);
- // Dashboard → GroupDetail (Chips)
+ // Dashboard → GroupDetail (Chips) / StudentDetail (Fehlzeiten-/Förderplan-Hinweise)
var dash = Services.GetRequiredService();
- dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
+ dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
+ dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
// StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService();
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index 51c1f26..976bd7a 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -99,6 +99,9 @@ public static class AppBootstrapper
services.AddSingleton(backup);
services.AddSingleton(_ => new AppLockService(appData));
services.AddSingleton();
+ services.AddSingleton(_ => new PrivacySettingsService(appData));
+ services.AddSingleton();
+ services.AddSingleton();
// ── Datenbank ─────────────────────────────────────────────────────────
services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
@@ -124,6 +127,7 @@ public static class AppBootstrapper
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton();
diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
index 0ddae11..20a5d47 100644
--- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
@@ -20,9 +20,12 @@ public partial class DashboardViewModel : ObservableObject
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participationEntries;
private readonly IStudentRepository _students;
+ private readonly IDocumentationRepository _documentation;
+ private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _sy;
private const int OpenExcuseMaxAgeDays = 21;
+ private const int SupportPlanDueWithinDays = 14;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
@@ -36,18 +39,22 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection CurrentGroups { get; } = [];
public ObservableCollection CalendarDays { get; } = [];
public ObservableCollection OpenExcuses { get; } = [];
+ public ObservableCollection AttendanceWarnings { get; } = [];
+ public ObservableCollection SupportPlanReviews { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback – wird von App.axaml.cs verdrahtet
public Action? OnNavigateToGroup { get; set; }
+ public Action? OnNavigateToStudent { get; set; }
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
- IParticipationRepository participationEntries, IStudentRepository students, SchoolYearService sy)
+ IParticipationRepository participationEntries, IStudentRepository students,
+ IDocumentationRepository documentation, AttendanceBalanceService attendanceBalance, SchoolYearService sy)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_participationSessions = participationSessions; _participationEntries = participationEntries;
- _students = students; _sy = sy;
+ _students = students; _documentation = documentation; _attendanceBalance = attendanceBalance; _sy = sy;
Load();
}
@@ -90,8 +97,64 @@ public partial class DashboardViewModel : ObservableObject
CalendarMonth = FirstOfMonth(now);
LoadCalendar();
LoadOpenExcuses(groups.Values.ToList(), today);
+ LoadAttendanceWarnings(today);
+ LoadSupportPlanReviews(today);
}
+ // ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
+
+ private void LoadAttendanceWarnings(DateOnly today)
+ {
+ AttendanceWarnings.Clear();
+ var schoolYear = _sy.CurrentSchoolYear();
+ var from = _sy.SchoolYearStart(schoolYear);
+ var to = _sy.SchoolYearEnd(schoolYear);
+
+ var items = new List();
+ foreach (var student in _students.GetAll())
+ {
+ var entries = _participationEntries.GetByStudent(student.Id)
+ .Select(e => _participationSessions.GetById(e.SessionId) is { } session
+ ? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance))
+ .Where(t => t.Item1.HasValue)
+ .Select(t => (t.Item1!.Value, t.Attendance));
+
+ var balance = _attendanceBalance.Calculate(entries, from, to);
+ if (balance.ExceedsThreshold)
+ items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
+ }
+ foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
+ AttendanceWarnings.Add(item);
+ }
+
+ // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
+
+ private void LoadSupportPlanReviews(DateOnly today)
+ {
+ SupportPlanReviews.Clear();
+ var dueBy = today.AddDays(SupportPlanDueWithinDays);
+
+ var due = _documentation.GetAll()
+ .Where(d => d.Type == DocumentationType.SupportPlan
+ && d.SupportData is { Status: SupportStatus.Active, ReviewDate: not null }
+ && d.SupportData.ReviewDate!.Value <= dueBy)
+ .OrderBy(d => d.SupportData!.ReviewDate);
+
+ foreach (var d in due)
+ {
+ var student = _students.GetById(d.StudentId);
+ if (student is null) continue;
+ SupportPlanReviews.Add(new SupportPlanDueItem(
+ d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today));
+ }
+ }
+
+ [RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item)
+ { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
+
+ [RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
+ { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
+
private void LoadOpenExcuses(List groups, DateOnly today)
{
OpenExcuses.Clear();
@@ -243,6 +306,35 @@ public partial class OpenExcuseItem : ObservableObject
[RelayCommand] private void MarkUnexcused() => OnResolve?.Invoke(this, AttendanceStatus.Unexcused);
}
+// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────────
+
+public class AttendanceWarningItem(Guid studentId, string studentName, double absenceRatePercent)
+{
+ public Guid StudentId { get; } = studentId;
+ public string StudentName { get; } = studentName;
+ public double AbsenceRatePercent { get; } = absenceRatePercent;
+}
+
+// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
+
+public class SupportPlanDueItem
+{
+ public Guid StudentId { get; }
+ public string StudentName { get; }
+ public string Title { get; }
+ public string ReviewDateDisplay { get; }
+ public bool IsOverdue { get; }
+
+ public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today)
+ {
+ StudentId = studentId;
+ StudentName = studentName;
+ Title = title;
+ ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy");
+ IsOverdue = reviewDate < today;
+ }
+}
+
public class CalendarDayCell
{
public int DayNumber { get; }
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
index a67b333..b3eeb87 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
@@ -23,6 +23,9 @@ public partial class SettingsViewModel : ObservableObject
private readonly DatabaseEncryptionService _dbEncryption;
private readonly AppLockService _appLock;
private readonly LiteDbContext _dbContext;
+ private readonly PrivacySettingsService _privacy;
+ private readonly IDocumentationRepository _documentation;
+ private readonly IStudentRepository _students;
// ── Fächer ────────────────────────────────────────────────────────────────
@@ -91,12 +94,23 @@ public partial class SettingsViewModel : ObservableObject
/// ohne dass die App neu gestartet werden muss.
public Action? OnAppLockChanged { get; set; }
+ // ── Datenschutz: Löschfristen (5.4.2) ────────────────────────────────────
+
+ [ObservableProperty] private int _retentionYears = 3;
+ [ObservableProperty] private string _retentionStatus = "";
+
+ public ObservableCollection ExpiredDocuments { get; } = [];
+
+ /// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
+ public Func>? OnConfirmHardDelete { get; set; }
+
// ── Konstruktor ───────────────────────────────────────────────────────────
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
- AppLockService appLock, LiteDbContext dbContext)
+ AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
+ IDocumentationRepository documentation, IStudentRepository students)
{
_subjects = subjects;
_domainRepo = domainRepo;
@@ -107,6 +121,9 @@ public partial class SettingsViewModel : ObservableObject
_dbEncryption = dbEncryption;
_appLock = appLock;
_dbContext = dbContext;
+ _privacy = privacy;
+ _documentation = documentation;
+ _students = students;
LoadSubjects();
LoadGradingKeyTemplates();
LoadGradingSchemes();
@@ -114,6 +131,38 @@ public partial class SettingsViewModel : ObservableObject
IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath);
AppLockEnabled = _appLock.IsEnabled;
AppLockTimeoutMinutes = _appLock.TimeoutMinutes;
+ RetentionYears = _privacy.RetentionYears;
+ LoadExpiredDocuments();
+ }
+
+ // ── Datenschutz: Löschfristen ─────────────────────────────────────────────
+
+ private void LoadExpiredDocuments()
+ {
+ ExpiredDocuments.Clear();
+ var cutoff = _privacy.RetentionCutoff();
+ foreach (var d in _documentation.GetAll().Where(d => d.CreatedAt < cutoff))
+ {
+ var student = _students.GetById(d.StudentId);
+ ExpiredDocuments.Add(new ExpiredDocumentItem(d, student?.FullName ?? "?"));
+ }
+ }
+
+ [RelayCommand]
+ private void SaveRetentionYears()
+ {
+ _privacy.SetRetentionYears(RetentionYears);
+ LoadExpiredDocuments();
+ RetentionStatus = "Gespeichert.";
+ }
+
+ [RelayCommand]
+ private async Task HardDeleteDocument(ExpiredDocumentItem? item)
+ {
+ if (item is null) return;
+ if (OnConfirmHardDelete is not null && !await OnConfirmHardDelete(item)) return;
+ _documentation.HardDelete(item.Id);
+ ExpiredDocuments.Remove(item);
}
// ── Datensicherung: Laden / Erstellen / Wiederherstellen ─────────────────
@@ -660,6 +709,14 @@ public class BackupListItem(BackupInfo info)
public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB";
}
+public class ExpiredDocumentItem(Documentation d, string studentName)
+{
+ public Guid Id { get; } = d.Id;
+ public string StudentName { get; } = studentName;
+ public string Title { get; } = d.Title;
+ public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
+}
+
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
internal class CatalogDto
diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
new file mode 100644
index 0000000..32a0e6f
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
@@ -0,0 +1,433 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using System.Collections.ObjectModel;
+using System.Globalization;
+
+namespace LehrerApp.Desktop.ViewModels.Students;
+
+// ── Dokumentation: deutsche Anzeige für Typ/Status (5.1) ─────────────────────
+
+public static class DocumentationTypeDisplay
+{
+ public static string[] Options { get; } =
+ ["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief"];
+
+ public static string Label(DocumentationType t) => t switch
+ {
+ DocumentationType.Conversation => "Gespräch",
+ DocumentationType.Incident => "Vorkommnis",
+ DocumentationType.SupportPlan => "Förderplan",
+ DocumentationType.Absence => "Fehlzeit",
+ DocumentationType.ParentCall => "Elternanruf",
+ DocumentationType.ParentLetter => "Elternbrief",
+ _ => "",
+ };
+
+ public static DocumentationType FromLabel(string label) => label switch
+ {
+ "Vorkommnis" => DocumentationType.Incident,
+ "Förderplan" => DocumentationType.SupportPlan,
+ "Fehlzeit" => DocumentationType.Absence,
+ "Elternanruf" => DocumentationType.ParentCall,
+ "Elternbrief" => DocumentationType.ParentLetter,
+ _ => DocumentationType.Conversation,
+ };
+}
+
+// ── Dokumentation: Labels zur Nachverfolgung ─────────────────────────────────
+
+public static class DocumentationTagDisplay
+{
+ // Vorschläge für die AutoCompleteBox im Dialog — eigene Labels sind trotzdem frei möglich.
+ public static string[] Suggestions { get; } =
+ [
+ "Kritisch", "Nacharbeiten", "Mit JGL abklären", "Erkundigung einholen",
+ "Elterngespräch nötig", "Mit Schulleitung abklären", "Klassenkonferenz",
+ "Frist beachten", "Beobachten", "Erledigt",
+ ];
+
+ public static string ColorHex(string tag) => tag switch
+ {
+ "Kritisch" or "Dringend" => "#E53935", // rot – Priorität
+ "Erledigt" => "#43A047", // grün – abgeschlossen
+ "Beobachten" or "Frist beachten" => "#1E88E5", // blau – im Blick behalten
+ "Nacharbeiten" or "Mit JGL abklären" or "Erkundigung einholen"
+ or "Elterngespräch nötig" or "Mit Schulleitung abklären" or "Klassenkonferenz"
+ => "#FB8C00", // orange – Handlungsbedarf
+ _ => "#757575", // grau – freies Label
+ };
+}
+
+public static class SupportStatusDisplay
+{
+ public static string[] Options { get; } = ["Aktiv", "Abgeschlossen", "Pausiert"];
+
+ public static string Label(SupportStatus s) => s switch
+ {
+ SupportStatus.Completed => "Abgeschlossen",
+ SupportStatus.Paused => "Pausiert",
+ _ => "Aktiv",
+ };
+
+ public static SupportStatus FromLabel(string label) => label switch
+ {
+ "Abgeschlossen" => SupportStatus.Completed,
+ "Pausiert" => SupportStatus.Paused,
+ _ => SupportStatus.Active,
+ };
+}
+
+// ── Dialog: Dokumentationseintrag anlegen/bearbeiten (5.1.1–5.1.3, 5.3.1) ────
+
+public partial class DocumentationDialogViewModel : ObservableObject
+{
+ private readonly IAttachmentStorage _attachmentStorage;
+ private readonly Documentation? _editing;
+ private readonly Guid _studentId;
+ private readonly List _newlyUploadedStorageIds = [];
+
+ [ObservableProperty] private string _typeName = DocumentationTypeDisplay.Options[0];
+ [ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
+ [ObservableProperty] private string _title = "";
+ [ObservableProperty] private string _content = "";
+ [ObservableProperty] private bool _isConfidential;
+
+ [ObservableProperty] private string _newParticipant = "";
+ public ObservableCollection Participants { get; } = [];
+
+ [ObservableProperty] private int _lessonCount = 1;
+ [ObservableProperty] private bool _absenceExcused;
+ [ObservableProperty] private string _absenceReason = "";
+
+ [ObservableProperty] private string _newMeasure = "";
+ public ObservableCollection Measures { get; } = [];
+ [ObservableProperty] private string _reviewDateText = "";
+ [ObservableProperty] private string _supportStatusName = SupportStatusDisplay.Options[0];
+
+ // Elternanruf (nur Planung — Abhaken/Eindrücke laufen über ParentCallSessionDialog)
+ [ObservableProperty] private string _newCallPoint = "";
+ public ObservableCollection CallPoints { get; } = [];
+
+ // Elternbrief
+ [ObservableProperty] private string _letterDraftContent = "";
+ [ObservableProperty] private string _letterSentDateText = "";
+ [ObservableProperty] private bool _letterResponseReceived;
+ [ObservableProperty] private string _letterResponseDateText = "";
+ [ObservableProperty] private string _letterResponseNote = "";
+ [ObservableProperty] private string _letterSentDateError = "";
+ [ObservableProperty] private string _letterResponseDateError = "";
+
+ // Anhänge (für alle Typen)
+ public ObservableCollection Attachments { get; } = [];
+ [ObservableProperty] private string _attachmentError = "";
+
+ // Labels zur Nachverfolgung (für alle Typen)
+ [ObservableProperty] private string _newTag = "";
+ public ObservableCollection Tags { get; } = [];
+ public string[] TagSuggestions => DocumentationTagDisplay.Suggestions;
+
+ [ObservableProperty] private string _titleError = "";
+ [ObservableProperty] private string _dateTextError = "";
+ [ObservableProperty] private string _reviewDateTextError = "";
+
+ public bool IsConversation => TypeName == "Gespräch";
+ public bool IsAbsence => TypeName == "Fehlzeit";
+ public bool IsSupportPlan => TypeName == "Förderplan";
+ public bool IsParentCall => TypeName == "Elternanruf";
+ public bool IsParentLetter => TypeName == "Elternbrief";
+
+ public string[] TypeOptions => DocumentationTypeDisplay.Options;
+ public string[] SupportStatusOptions => SupportStatusDisplay.Options;
+ public string DialogTitle => _editing is null ? "Dokumentation hinzufügen" : "Dokumentation bearbeiten";
+ public long MaxAttachmentSizeBytes => IAttachmentStorage.MaxSizeBytes;
+
+ public Documentation? Result { get; private set; }
+
+ public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage)
+ {
+ _studentId = studentId;
+ _editing = editing;
+ _attachmentStorage = attachmentStorage;
+ if (editing is null) return;
+
+ TypeName = DocumentationTypeDisplay.Label(editing.Type);
+ DateText = editing.Date.ToString("dd.MM.yyyy");
+ Title = editing.Title;
+ Content = editing.Content;
+ IsConfidential = editing.IsConfidential;
+ foreach (var p in editing.Participants) Participants.Add(p);
+ if (editing.AbsenceData is { } a)
+ {
+ LessonCount = a.LessonCount;
+ AbsenceExcused = a.Excused;
+ AbsenceReason = a.Reason ?? "";
+ }
+ if (editing.SupportData is { } s)
+ {
+ foreach (var m in s.Measures) Measures.Add(m);
+ ReviewDateText = s.ReviewDate?.ToString("dd.MM.yyyy") ?? "";
+ SupportStatusName = SupportStatusDisplay.Label(s.Status);
+ }
+ if (editing.ParentCallData is { } pc)
+ foreach (var point in pc.Points) CallPoints.Add(point.Text);
+ if (editing.ParentLetterData is { } pl)
+ {
+ LetterDraftContent = pl.DraftContent;
+ LetterSentDateText = pl.SentDate?.ToString("dd.MM.yyyy") ?? "";
+ LetterResponseReceived = pl.ResponseReceived;
+ LetterResponseDateText = pl.ResponseDate?.ToString("dd.MM.yyyy") ?? "";
+ LetterResponseNote = pl.ResponseNote;
+ }
+ foreach (var att in editing.Attachments)
+ Attachments.Add(new AttachmentItem(att.StorageId, att.FileName, att.SizeBytes, att.UploadedAt));
+ foreach (var tag in editing.Tags) Tags.Add(tag);
+ }
+
+ partial void OnTypeNameChanged(string value)
+ {
+ OnPropertyChanged(nameof(IsConversation));
+ OnPropertyChanged(nameof(IsAbsence));
+ OnPropertyChanged(nameof(IsSupportPlan));
+ OnPropertyChanged(nameof(IsParentCall));
+ OnPropertyChanged(nameof(IsParentLetter));
+ }
+
+ // ── Anhänge ────────────────────────────────────────────────────────────
+
+ public void AddAttachment(string fileName, Stream content)
+ {
+ AttachmentError = "";
+ if (content.Length > IAttachmentStorage.MaxSizeBytes)
+ {
+ AttachmentError = $"Datei zu groß (max. {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB).";
+ return;
+ }
+ var storageId = _attachmentStorage.Upload(fileName, content);
+ _newlyUploadedStorageIds.Add(storageId);
+ Attachments.Add(new AttachmentItem(storageId, fileName, content.Length, DateTime.UtcNow));
+ }
+
+ public Stream? OpenAttachment(AttachmentItem item) => _attachmentStorage.OpenRead(item.StorageId);
+
+ [RelayCommand]
+ private void RemoveAttachment(AttachmentItem? item)
+ {
+ if (item is null) return;
+ _attachmentStorage.Delete(item.StorageId);
+ _newlyUploadedStorageIds.Remove(item.StorageId);
+ Attachments.Remove(item);
+ }
+
+ /// Vom Code-Behind beim Abbrechen aufgerufen: neu hochgeladene, nie gespeicherte Anhänge
+ /// wieder entfernen, damit keine verwaisten Blobs in der Datenbank zurückbleiben.
+ public void DiscardUnsavedAttachments()
+ {
+ foreach (var id in _newlyUploadedStorageIds) _attachmentStorage.Delete(id);
+ }
+
+ [RelayCommand]
+ private void AddParticipant()
+ {
+ if (string.IsNullOrWhiteSpace(NewParticipant)) return;
+ Participants.Add(NewParticipant.Trim());
+ NewParticipant = "";
+ }
+
+ [RelayCommand]
+ private void RemoveParticipant(string? p) { if (p is not null) Participants.Remove(p); }
+
+ [RelayCommand]
+ private void AddMeasure()
+ {
+ if (string.IsNullOrWhiteSpace(NewMeasure)) return;
+ Measures.Add(NewMeasure.Trim());
+ NewMeasure = "";
+ }
+
+ [RelayCommand]
+ private void RemoveMeasure(string? m) { if (m is not null) Measures.Remove(m); }
+
+ [RelayCommand]
+ private void AddCallPoint()
+ {
+ if (string.IsNullOrWhiteSpace(NewCallPoint)) return;
+ CallPoints.Add(NewCallPoint.Trim());
+ NewCallPoint = "";
+ }
+
+ [RelayCommand]
+ private void RemoveCallPoint(string? p) { if (p is not null) CallPoints.Remove(p); }
+
+ [RelayCommand]
+ private void AddTag()
+ {
+ if (string.IsNullOrWhiteSpace(NewTag)) return;
+ var tag = NewTag.Trim();
+ if (Tags.Contains(tag)) return;
+ Tags.Add(tag);
+ NewTag = "";
+ }
+
+ [RelayCommand]
+ private void RemoveTag(string? t) { if (t is not null) Tags.Remove(t); }
+
+ [RelayCommand]
+ private void Save()
+ {
+ TitleError = ""; DateTextError = ""; ReviewDateTextError = "";
+ LetterSentDateError = ""; LetterResponseDateError = "";
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
+
+ if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
+ { DateTextError = "Format TT.MM.JJJJ."; valid = false; }
+
+ DateOnly? reviewDate = null;
+ if (IsSupportPlan && !string.IsNullOrWhiteSpace(ReviewDateText))
+ {
+ if (!DateOnly.TryParseExact(ReviewDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
+ { ReviewDateTextError = "Format TT.MM.JJJJ."; valid = false; }
+ else reviewDate = r;
+ }
+
+ DateOnly? sentDate = null;
+ if (IsParentLetter && !string.IsNullOrWhiteSpace(LetterSentDateText))
+ {
+ if (!DateOnly.TryParseExact(LetterSentDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var s))
+ { LetterSentDateError = "Format TT.MM.JJJJ."; valid = false; }
+ else sentDate = s;
+ }
+
+ DateOnly? responseDate = null;
+ if (IsParentLetter && !string.IsNullOrWhiteSpace(LetterResponseDateText))
+ {
+ if (!DateOnly.TryParseExact(LetterResponseDateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var r))
+ { LetterResponseDateError = "Format TT.MM.JJJJ."; valid = false; }
+ else responseDate = r;
+ }
+
+ if (!valid) return;
+
+ var type = DocumentationTypeDisplay.FromLabel(TypeName);
+ Result = _editing ?? new Documentation { StudentId = _studentId };
+ Result.Type = type;
+ Result.Date = date;
+ Result.Title = Title.Trim();
+ Result.Content = (Content ?? "").Trim();
+ Result.IsConfidential = IsConfidential;
+ Result.Participants = type == DocumentationType.Conversation ? Participants.ToList() : [];
+ Result.AbsenceData = type == DocumentationType.Absence
+ ? new AbsenceData
+ {
+ LessonCount = LessonCount,
+ Excused = AbsenceExcused,
+ Reason = string.IsNullOrWhiteSpace(AbsenceReason) ? null : AbsenceReason.Trim(),
+ }
+ : null;
+ Result.SupportData = type == DocumentationType.SupportPlan
+ ? new SupportData
+ {
+ Measures = Measures.ToList(),
+ ReviewDate = reviewDate,
+ Status = SupportStatusDisplay.FromLabel(SupportStatusName),
+ }
+ : null;
+
+ if (type == DocumentationType.ParentCall)
+ {
+ var existingPoints = _editing?.ParentCallData?.Points ?? [];
+ Result.ParentCallData = new ParentCallData
+ {
+ // Punkte mit unverändertem Text behalten ihren Abhak-Status; neue/umbenannte
+ // Punkte starten offen — das Abhaken selbst läuft über ParentCallSessionDialog.
+ Points = CallPoints.Select(text =>
+ existingPoints.FirstOrDefault(p => p.Text == text) ?? new ParentCallPoint { Text = text }).ToList(),
+ Impressions = _editing?.ParentCallData?.Impressions ?? "",
+ IsConducted = _editing?.ParentCallData?.IsConducted ?? false,
+ ConductedDate = _editing?.ParentCallData?.ConductedDate,
+ };
+ }
+ else Result.ParentCallData = null;
+
+ Result.ParentLetterData = type == DocumentationType.ParentLetter
+ ? new ParentLetterData
+ {
+ DraftContent = (LetterDraftContent ?? "").Trim(),
+ SentDate = sentDate,
+ ResponseReceived = LetterResponseReceived,
+ ResponseDate = responseDate,
+ ResponseNote = (LetterResponseNote ?? "").Trim(),
+ }
+ : null;
+
+ Result.Attachments = Attachments.Select(a => new DocumentAttachment
+ {
+ StorageId = a.StorageId, FileName = a.FileName, SizeBytes = a.SizeBytes, UploadedAt = a.UploadedAt,
+ }).ToList();
+ Result.Tags = Tags.ToList();
+ // Nach erfolgreichem Speichern sollen diese Anhänge NICHT mehr beim Abbrechen-Cleanup
+ // gelöscht werden — der Aufrufer speichert den Eintrag direkt im Anschluss an Save().
+ _newlyUploadedStorageIds.Clear();
+ }
+}
+
+public class AttachmentItem(string storageId, string fileName, long sizeBytes, DateTime uploadedAt)
+{
+ public string StorageId { get; } = storageId;
+ public string FileName { get; } = fileName;
+ public long SizeBytes { get; } = sizeBytes;
+ public DateTime UploadedAt { get; } = uploadedAt;
+ public string SizeDisplay => $"{SizeBytes / 1024.0:0} KB";
+}
+
+public class TagChip(string text)
+{
+ public string Text { get; } = text;
+ public string ColorHex { get; } = DocumentationTagDisplay.ColorHex(text);
+}
+
+// ── Listen-Eintrag mit Vertraulichkeits-Freigabe (5.1.3/5.4.1) ───────────────
+
+public partial class DocumentationItem : ObservableObject
+{
+ public Documentation Model { get; }
+ public string DateDisplay { get; }
+ public string TypeLabel { get; }
+ public bool IsConfidential { get; }
+ public bool IsParentCall { get; }
+ public bool HasAttachments { get; }
+ public string StatusLabel { get; }
+ public List TagChips { get; }
+
+ [ObservableProperty] private bool _isRevealed;
+
+ public DocumentationItem(Documentation d)
+ {
+ Model = d;
+ DateDisplay = d.Date.ToString("dd.MM.yyyy");
+ TypeLabel = DocumentationTypeDisplay.Label(d.Type);
+ IsConfidential = d.IsConfidential;
+ IsRevealed = !d.IsConfidential;
+ IsParentCall = d.Type == DocumentationType.ParentCall;
+ HasAttachments = d.Attachments.Count > 0;
+ StatusLabel = BuildStatusLabel(d);
+ TagChips = d.Tags.Select(t => new TagChip(t)).ToList();
+ }
+
+ private static string BuildStatusLabel(Documentation d) => d.Type switch
+ {
+ DocumentationType.ParentCall when d.ParentCallData is { IsConducted: true } pc =>
+ $"Durchgeführt am {pc.ConductedDate:dd.MM.yyyy}",
+ DocumentationType.ParentCall => "Noch nicht durchgeführt",
+ DocumentationType.ParentLetter when d.ParentLetterData is { } pl =>
+ pl.ResponseReceived ? "Rückmeldung erhalten"
+ : pl.SentDate.HasValue ? $"Versendet am {pl.SentDate:dd.MM.yyyy}"
+ : "Noch nicht versendet",
+ _ => "",
+ };
+
+ [RelayCommand] private void Reveal() => IsRevealed = true;
+}
diff --git a/LehrerApp.Desktop/ViewModels/Students/ParentCallSessionViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/ParentCallSessionViewModel.cs
new file mode 100644
index 0000000..816a526
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Students/ParentCallSessionViewModel.cs
@@ -0,0 +1,58 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using LehrerApp.Core.Models;
+using System.Collections.ObjectModel;
+
+namespace LehrerApp.Desktop.ViewModels.Students;
+
+/// Ein Gesprächspunkt während des begleiteten Elternanrufs, abhakbar (5.1: Elternanruf).
+public partial class ParentCallPointItem : ObservableObject
+{
+ public Guid Id { get; }
+ public string Text { get; }
+
+ [ObservableProperty] private bool _isDone;
+
+ public ParentCallPointItem(ParentCallPoint p)
+ {
+ Id = p.Id;
+ Text = p.Text;
+ IsDone = p.IsDone;
+ }
+}
+
+///
+/// Begleitet einen Elternanruf während des Gesprächs: geplante Punkte abhaken, Eindrücke und
+/// Ergänzungen festhalten, als Protokoll speichern.
+///
+public partial class ParentCallSessionViewModel : ObservableObject
+{
+ private readonly Documentation _documentation;
+
+ public string StudentTitle { get; }
+ public ObservableCollection Points { get; } = [];
+
+ [ObservableProperty] private string _impressions;
+
+ public Documentation? Result { get; private set; }
+
+ public ParentCallSessionViewModel(Documentation documentation, string studentTitle)
+ {
+ _documentation = documentation;
+ StudentTitle = studentTitle;
+ _impressions = documentation.ParentCallData?.Impressions ?? "";
+ foreach (var p in documentation.ParentCallData?.Points ?? [])
+ Points.Add(new ParentCallPointItem(p));
+ }
+
+ public void SaveProtocol()
+ {
+ _documentation.ParentCallData = new ParentCallData
+ {
+ Points = Points.Select(p => new ParentCallPoint { Id = p.Id, Text = p.Text, IsDone = p.IsDone }).ToList(),
+ Impressions = Impressions.Trim(),
+ IsConducted = true,
+ ConductedDate = DateOnly.FromDateTime(DateTime.Today),
+ };
+ Result = _documentation;
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
index 7dcfd67..b0e19a8 100644
--- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -79,6 +80,11 @@ public partial class StudentDetailViewModel : ObservableObject
private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IGradeRepository _grades;
+ private readonly IParticipationRepository _participation;
+ private readonly IParticipationSessionRepository _participationSessions;
+ private readonly AttendanceBalanceService _attendanceBalance;
+ private readonly PersonalDataExportService _export;
+ private readonly SchoolYearService _schoolYear;
[ObservableProperty] private Student? _student;
[ObservableProperty] private string _studentTitle = "";
@@ -86,23 +92,33 @@ public partial class StudentDetailViewModel : ObservableObject
[ObservableProperty] private string _editFirstName = "";
[ObservableProperty] private string _editLastName = "";
[ObservableProperty] private ContactItem? _selectedContact;
+ [ObservableProperty] private AttendanceBalance? _attendance;
+ [ObservableProperty] private string _exportStatus = "";
public ObservableCollection GroupMemberships { get; } = [];
- public ObservableCollection Documentation { get; } = [];
+ public ObservableCollection Documentation { get; } = [];
public ObservableCollection Contacts { get; } = [];
public ObservableCollection GradeHistory { get; } = [];
public bool HasNoContacts => Contacts.Count == 0;
public Func>? OnEditContact { get; set; }
public Action? OnViewAddress { get; set; }
+ public Func>? OnEditDocumentation { get; set; }
+ public Func>? OnConfirmDeleteDocumentation { get; set; }
+ public Func? OnSaveExportFile { get; set; }
+ public Func>? OnConductParentCall { get; set; }
public StudentDetailViewModel(IStudentRepository students,
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults,
- IGradeRepository grades)
+ IGradeRepository grades, IParticipationRepository participation,
+ IParticipationSessionRepository participationSessions, AttendanceBalanceService attendanceBalance,
+ PersonalDataExportService export, SchoolYearService schoolYear)
{
_students = students; _memberships = memberships;
_groups = groups; _subjects = subjects; _docs = docs;
_exams = exams; _examResults = examResults; _grades = grades;
+ _participation = participation; _participationSessions = participationSessions;
+ _attendanceBalance = attendanceBalance; _export = export; _schoolYear = schoolYear;
}
public void LoadStudent(Guid id)
@@ -127,19 +143,86 @@ public partial class StudentDetailViewModel : ObservableObject
}
LoadContacts();
+ LoadDocumentation();
+ LoadAttendanceBalance();
+ }
+ // ── Dokumentation (5.1) ───────────────────────────────────────────────────
+
+ private void LoadDocumentation()
+ {
+ if (Student is null) return;
Documentation.Clear();
foreach (var d in _docs.GetByStudent(Student.Id))
- Documentation.Add(new() { Date = d.Date.ToString("dd.MM.yyyy"), Title = d.Title,
- TypeLabel = d.Type switch
- {
- DocumentationType.Conversation => "Gespräch",
- DocumentationType.Incident => "Vorkommnis",
- DocumentationType.SupportPlan => "Förderplan",
- DocumentationType.Absence => "Fehlzeit",
- _ => "",
- },
- IsConfidential = d.IsConfidential });
+ Documentation.Add(new DocumentationItem(d));
+ }
+
+ [RelayCommand]
+ private async Task AddDocumentation()
+ {
+ if (Student is null || OnEditDocumentation is null) return;
+ var result = await OnEditDocumentation(Student.Id, null);
+ if (result is null) return;
+ _docs.Save(result);
+ LoadDocumentation();
+ }
+
+ [RelayCommand]
+ private async Task EditDocumentation(DocumentationItem? item)
+ {
+ if (Student is null || item is null || OnEditDocumentation is null) return;
+ var result = await OnEditDocumentation(Student.Id, item.Model);
+ if (result is null) return;
+ _docs.Save(result);
+ LoadDocumentation();
+ }
+
+ [RelayCommand]
+ private async Task DeleteDocumentation(DocumentationItem? item)
+ {
+ if (item is null) return;
+ if (OnConfirmDeleteDocumentation is not null && !await OnConfirmDeleteDocumentation(item)) return;
+ _docs.Delete(item.Model.Id);
+ LoadDocumentation();
+ }
+
+ [RelayCommand]
+ private async Task ConductParentCall(DocumentationItem? item)
+ {
+ if (Student is null || item is null || OnConductParentCall is null) return;
+ var result = await OnConductParentCall(item.Model, Student.FullName);
+ if (result is null) return;
+ _docs.Save(result);
+ LoadDocumentation();
+ }
+
+ // ── Fehlzeitenbilanz (5.2.2/5.2.3) ────────────────────────────────────────
+
+ private void LoadAttendanceBalance()
+ {
+ if (Student is null) return;
+ var schoolYear = _schoolYear.CurrentSchoolYear();
+ var from = _schoolYear.SchoolYearStart(schoolYear);
+ var to = _schoolYear.SchoolYearEnd(schoolYear);
+
+ var entries = _participation.GetByStudent(Student.Id)
+ .Select(e => _participationSessions.GetById(e.SessionId) is { } session
+ ? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance))
+ .Where(t => t.Item1.HasValue)
+ .Select(t => (t.Item1!.Value, t.Attendance));
+
+ Attendance = _attendanceBalance.Calculate(entries, from, to);
+ }
+
+ // ── Datenauskunft (5.4.3) ─────────────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task ExportPersonalData()
+ {
+ if (Student is null || OnSaveExportFile is null) return;
+ var json = _export.ExportAsJson(Student.Id);
+ await OnSaveExportFile(json);
+ ExportStatus = "Datenauskunft exportiert.";
}
// ── Notenentwicklung (2.5) ────────────────────────────────────────────────
@@ -255,7 +338,6 @@ public partial class StudentDetailViewModel : ObservableObject
}
public class GroupMembershipEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; }
-public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } }
// ── Notenentwicklung (2.5) ──────────────────────────────────────────────────
diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
index b6a649f..5693422 100644
--- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
+++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
@@ -4,6 +4,13 @@
x:Class="LehrerApp.Desktop.Views.Dashboard.DashboardView"
x:DataType="vm:DashboardViewModel">
+
+
+
+
@@ -13,7 +20,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index 6dc74df..6812a55 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -378,6 +378,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
index f21d64c..142c39d 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
@@ -19,6 +19,7 @@ public partial class SettingsView : UserControl
{
vm.OnConfirmRestore = ShowRestoreConfirmDialog;
vm.OnAppLockChanged = () => App.Services.GetRequiredService().ApplyConfig();
+ vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
}
}
@@ -36,6 +37,19 @@ public partial class SettingsView : UserControl
return owner is not null && await dialog.ShowDialog(owner);
}
+ private async Task ShowHardDeleteConfirmDialog(ExpiredDocumentItem item)
+ {
+ var info = new ConfirmDialogInfo
+ {
+ Title = "Eintrag endgültig löschen?",
+ Message = $"\"{item.Title}\" ({item.StudentName}) wird unwiderruflich gelöscht, nicht nur ausgeblendet.",
+ ConfirmText = "Endgültig löschen",
+ };
+ var dialog = new ConfirmDialog { DataContext = info };
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ return owner is not null && await dialog.ShowDialog(owner);
+ }
+
private async void OnImportClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
diff --git a/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml b/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml
new file mode 100644
index 0000000..baabde3
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml.cs b/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml.cs
new file mode 100644
index 0000000..ee9e953
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml.cs
@@ -0,0 +1,58 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using LehrerApp.Desktop.ViewModels.Students;
+
+namespace LehrerApp.Desktop.Views.Students;
+
+public partial class DocumentationDialog : Window
+{
+ public DocumentationDialog() => InitializeComponent();
+
+ private void OnSave(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is DocumentationDialogViewModel vm)
+ {
+ vm.SaveCommand.Execute(null);
+ if (vm.Result is not null) Close(true);
+ }
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is DocumentationDialogViewModel vm) vm.DiscardUnsavedAttachments();
+ Close(false);
+ }
+
+ private async void OnAddAttachment(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not DocumentationDialogViewModel vm) return;
+
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Anhang auswählen", AllowMultiple = false,
+ });
+ if (files.Count == 0) return;
+
+ await using var stream = await files[0].OpenReadAsync();
+ vm.AddAttachment(files[0].Name, stream);
+ }
+
+ private async void OnOpenAttachment(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not DocumentationDialogViewModel vm) return;
+ if (sender is not Button { Tag: AttachmentItem item }) return;
+
+ using var source = vm.OpenAttachment(item);
+ if (source is null) return;
+
+ var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Anhang speichern unter", SuggestedFileName = item.FileName,
+ });
+ if (file is null) return;
+
+ await using var target = await file.OpenWriteAsync();
+ await source.CopyToAsync(target);
+ }
+}
diff --git a/LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml b/LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml
new file mode 100644
index 0000000..574bbc3
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml.cs b/LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml.cs
new file mode 100644
index 0000000..306c223
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml.cs
@@ -0,0 +1,21 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.ViewModels.Students;
+
+namespace LehrerApp.Desktop.Views.Students;
+
+public partial class ParentCallSessionDialog : Window
+{
+ public ParentCallSessionDialog() => InitializeComponent();
+
+ private void OnSave(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is ParentCallSessionViewModel vm)
+ {
+ vm.SaveProtocol();
+ Close(true);
+ }
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
index f80c143..caf7b3a 100644
--- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
+++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml
@@ -131,6 +131,18 @@
+
+
+
+
+
+
+
+
+
+
@@ -160,6 +172,8 @@
Classes.warning="{Binding IsWarning}"/>
+
@@ -188,27 +202,101 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs
index 5d1114b..30e3e27 100644
--- a/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Students/StudentDetailView.axaml.cs
@@ -1,7 +1,11 @@
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Platform.Storage;
+using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
+using LehrerApp.Desktop.Views.Shared;
+using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Students;
@@ -15,6 +19,10 @@ public partial class StudentDetailView : UserControl
if (DataContext is not StudentDetailViewModel vm) return;
vm.OnEditContact = ShowContactDialog;
vm.OnViewAddress = ShowAddressViewer;
+ vm.OnEditDocumentation = ShowDocumentationDialog;
+ vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
+ vm.OnSaveExportFile = SaveExportFile;
+ vm.OnConductParentCall = ShowParentCallSessionDialog;
}
private async Task ShowContactDialog(Contact? contact)
@@ -45,4 +53,57 @@ public partial class StudentDetailView : UserControl
vm.ViewSelectedAddressCommand.CanExecute(null))
vm.ViewSelectedAddressCommand.Execute(null);
}
+
+ private async Task ShowDocumentationDialog(Guid studentId, Documentation? editing)
+ {
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ if (owner is null) return null;
+
+ var vm = new DocumentationDialogViewModel(studentId, editing,
+ App.Services.GetRequiredService());
+ var dialog = new DocumentationDialog { DataContext = vm };
+ var saved = await dialog.ShowDialog(owner);
+ if (!saved) vm.DiscardUnsavedAttachments();
+ return saved ? vm.Result : null;
+ }
+
+ private async Task ShowParentCallSessionDialog(Documentation documentation, string studentTitle)
+ {
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ if (owner is null) return null;
+
+ var vm = new ParentCallSessionViewModel(documentation, studentTitle);
+ var dialog = new ParentCallSessionDialog { DataContext = vm };
+ var saved = await dialog.ShowDialog(owner);
+ return saved ? vm.Result : null;
+ }
+
+ private async Task ShowDeleteDocumentationDialog(DocumentationItem item)
+ {
+ var info = new ConfirmDialogInfo
+ {
+ Title = "Eintrag löschen?",
+ Message = $"\"{item.Model.Title}\" wird als gelöscht markiert und nicht mehr angezeigt.",
+ ConfirmText = "Löschen",
+ };
+ var dialog = new ConfirmDialog { DataContext = info };
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ return owner is not null && await dialog.ShowDialog(owner);
+ }
+
+ private async Task SaveExportFile(string json)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is null || DataContext is not StudentDetailViewModel vm || vm.Student is null) return;
+
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Datenauskunft exportieren",
+ SuggestedFileName = $"Datenauskunft_{vm.Student.LastName}_{vm.Student.FirstName}.json",
+ FileTypeChoices = [new FilePickerFileType("JSON-Dateien") { Patterns = ["*.json"] }],
+ });
+
+ if (file is null) return;
+ await File.WriteAllTextAsync(file.Path.LocalPath, json);
+ }
}
diff --git a/LehrerApp.Tests/AttendanceBalanceServiceTests.cs b/LehrerApp.Tests/AttendanceBalanceServiceTests.cs
new file mode 100644
index 0000000..a1d2713
--- /dev/null
+++ b/LehrerApp.Tests/AttendanceBalanceServiceTests.cs
@@ -0,0 +1,88 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class AttendanceBalanceServiceTests
+{
+ private readonly AttendanceBalanceService _service = new();
+ private static readonly DateOnly From = new(2025, 8, 1);
+ private static readonly DateOnly To = new(2026, 7, 31);
+
+ [Fact]
+ public void Calculate_ZaehltJedenStatusKorrekt()
+ {
+ var entries = new List<(DateOnly, AttendanceStatus?)>
+ {
+ (new DateOnly(2025, 9, 1), AttendanceStatus.Present),
+ (new DateOnly(2025, 9, 2), AttendanceStatus.Present),
+ (new DateOnly(2025, 9, 3), AttendanceStatus.Excused),
+ (new DateOnly(2025, 9, 4), AttendanceStatus.Unexcused),
+ (new DateOnly(2025, 9, 5), AttendanceStatus.Truant),
+ (new DateOnly(2025, 9, 6), AttendanceStatus.OtherSchoolEvent),
+ (new DateOnly(2025, 9, 7), AttendanceStatus.ExcusePending),
+ };
+
+ var balance = _service.Calculate(entries, From, To);
+
+ Assert.Equal(7, balance.TotalChecked);
+ Assert.Equal(2, balance.Present);
+ Assert.Equal(1, balance.Excused);
+ Assert.Equal(2, balance.Unexcused); // Unexcused + Truant
+ Assert.Equal(1, balance.SchoolEvent);
+ Assert.Equal(1, balance.ExcusePending);
+ }
+
+ [Fact]
+ public void Calculate_IgnoriertEintraegeOhneStatusUndAusserhalbDesZeitraums()
+ {
+ var entries = new List<(DateOnly, AttendanceStatus?)>
+ {
+ (new DateOnly(2025, 9, 1), null),
+ (new DateOnly(2024, 9, 1), AttendanceStatus.Unexcused), // vor dem Zeitraum
+ (new DateOnly(2025, 9, 1), AttendanceStatus.Present),
+ };
+
+ var balance = _service.Calculate(entries, From, To);
+
+ Assert.Equal(1, balance.TotalChecked);
+ Assert.Equal(1, balance.Present);
+ }
+
+ [Fact]
+ public void Calculate_SchulischVeranlassteAbwesenheitZaehltNichtAlsFehlzeit()
+ {
+ var entries = new List<(DateOnly, AttendanceStatus?)>
+ {
+ (new DateOnly(2025, 9, 1), AttendanceStatus.OtherSchoolEvent),
+ (new DateOnly(2025, 9, 2), AttendanceStatus.Present),
+ };
+
+ var balance = _service.Calculate(entries, From, To);
+
+ Assert.Equal(0.0, balance.AbsenceRatePercent);
+ }
+
+ [Fact]
+ public void ExceedsThreshold_UeberZwanzigProzentFehlquote_IstTrue()
+ {
+ var entries = Enumerable.Range(0, 10)
+ .Select(i => (From.AddDays(i), (AttendanceStatus?)(i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present)))
+ .ToList();
+
+ var balance = _service.Calculate(entries, From, To);
+
+ Assert.Equal(30.0, balance.AbsenceRatePercent);
+ Assert.True(balance.ExceedsThreshold);
+ }
+
+ [Fact]
+ public void ExceedsThreshold_KeineKontrolliertenStunden_IstFalseUndRateIstNull()
+ {
+ var balance = _service.Calculate([], From, To);
+
+ Assert.Equal(0.0, balance.AbsenceRatePercent);
+ Assert.False(balance.ExceedsThreshold);
+ }
+}
diff --git a/LehrerApp.Tests/PrivacySettingsServiceTests.cs b/LehrerApp.Tests/PrivacySettingsServiceTests.cs
new file mode 100644
index 0000000..2654af6
--- /dev/null
+++ b/LehrerApp.Tests/PrivacySettingsServiceTests.cs
@@ -0,0 +1,60 @@
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class PrivacySettingsServiceTests
+{
+ [Fact]
+ public void NeueKonfiguration_HatDreiJahreAlsStandard()
+ {
+ using var temp = new TempAppData();
+ var service = new PrivacySettingsService(temp.Path);
+
+ Assert.Equal(3, service.RetentionYears);
+ }
+
+ [Fact]
+ public void SetRetentionYears_WirdUeberNeueInstanzHinwegPersistiert()
+ {
+ using var temp = new TempAppData();
+ new PrivacySettingsService(temp.Path).SetRetentionYears(5);
+
+ var second = new PrivacySettingsService(temp.Path);
+
+ Assert.Equal(5, second.RetentionYears);
+ }
+
+ [Fact]
+ public void SetRetentionYears_WertUnterEinsWirdAufEinsBegrenzt()
+ {
+ using var temp = new TempAppData();
+ var service = new PrivacySettingsService(temp.Path);
+
+ service.SetRetentionYears(0);
+
+ Assert.Equal(1, service.RetentionYears);
+ }
+
+ [Fact]
+ public void RetentionCutoff_LiegtRetentionYearsVorDemStichtag()
+ {
+ using var temp = new TempAppData();
+ var service = new PrivacySettingsService(temp.Path);
+ service.SetRetentionYears(3);
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ var cutoff = service.RetentionCutoff(now);
+
+ Assert.Equal(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), cutoff);
+ }
+
+ private sealed class TempAppData : IDisposable
+ {
+ public string Path { get; } = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), $"lehrerapp-privacy-tests-{Guid.NewGuid():N}");
+
+ public TempAppData() => Directory.CreateDirectory(Path);
+ public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
+ }
+}
diff --git a/TODO.md b/TODO.md
index 8e5bd13..4f6c28f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -141,7 +141,12 @@ Umgesetzt über [GradeOverviewViewModels.cs](LehrerApp.Desktop/ViewModels/Groups
die Voreinstellung des Gruppentyps (Einstellungen → Notenschema), sonst ein Fallback 50/40/10 verwendet;
Bereiche ohne Werte werden bei der Berechnung ausgelassen und die verbleibenden Prozentanteile neu normiert
(`GradingService.CalculateReportGrade()`). Notenentwicklung im Schülerdetail zeigt ein einfaches
-Balken-Sparkline je Lerngruppe über alle Klausur- und Einzelnoten-Einträge chronologisch.
+Balken-Sparkline je Lerngruppe über alle Klausur- und Einzelnoten-Einträge chronologisch — **jeder
+Balken ist ein einzelner `Grade`- bzw. Klausurergebnis-Eintrag, keine Mitarbeit-"Sitzung"**. Die
+Herkunft (Kategorie wie "Mündlich"/"Mitarbeit" oder Klausurtitel) stand ursprünglich nur im
+Tooltip und nicht sichtbar auf der Kachel — das führte zu Verwirrung, welche Zahl wofür steht,
+und wurde ergänzt (`GradeHistoryPoint.Label` jetzt auch unter dem Balken sichtbar, nicht nur im
+Tooltip), siehe [StudentDetailView.axaml](LehrerApp.Desktop/Views/Students/StudentDetailView.axaml).
---
@@ -255,33 +260,114 @@ Navigationspunkt "Unterrichtsplanung" ist ein `PlaceholderViewModel`
## 5. Schülerdokumentation
-Modelle `Documentation`, `AbsenceData`, `SupportData` existieren, Repository ebenfalls.
-Der Tab "Dokumentation" in der Gruppenansicht ist ein Platzhalter, im Schülerdetail
-werden `DocEntry`-Einträge bereits gelesen.
+**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
+dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
+bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
+`AttendanceStatus`) ergeben hätte — zwei potenziell widersprüchliche Datenquellen für dieselbe
+Frage ("war der Schüler da?"). Auf Rückfrage entschieden: 5.2 wird als **Auswertung** der
+bestehenden Anwesenheitsdaten umgesetzt, keine zweite Erfassung. 5.2.1 und 5.2.4 existierten
+dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Dashboard).
### 5.1 Einträge erfassen
-- [ ] **5.1.1** Dialog "Dokumentation hinzufügen" mit Typwahl
- (`Conversation`, `Incident`, `SupportPlan`, `Absence`) und typabhängigen Feldern.
-- [ ] **5.1.2** Teilnehmerliste (`Participants`) bei Gesprächen erfassen.
-- [ ] **5.1.3** Kennzeichen "vertraulich" (`IsConfidential`) mit Ausblenden in Übersichten.
-- [ ] **5.1.4** Bearbeiten/Löschen mit Rückfrage; gelöschte Einträge nicht hart entfernen,
- sondern als gelöscht markieren (Nachvollziehbarkeit).
+- [x] **5.1.1** Dialog "Dokumentation hinzufügen" mit Typwahl
+ (`Conversation`, `Incident`, `SupportPlan`, `Absence`) und typabhängigen Feldern —
+ [DocumentationDialog.axaml](LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml),
+ [DocumentationViewModels.cs](LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs).
+ Deutsche Anzeige über `DocumentationTypeDisplay`/`SupportStatusDisplay`, analog zum
+ `NiveauDisplay`-Muster. Feldbezogene Validierung wie in 13.2.4.
+- [x] **5.1.2** Teilnehmerliste (`Participants`) bei Gesprächen — Chip-Liste mit Hinzufügen/
+ Entfernen im Dialog, nur sichtbar bei Typ "Gespräch".
+- [x] **5.1.3** Kennzeichen "vertraulich" — vertrauliche Einträge zeigen in der Übersicht nur
+ Datum/Typ/Schloss-Symbol, Titel und Inhalt erst nach Klick auf "Anzeigen"
+ (`DocumentationItem.IsRevealed`, session-lokal, keine erneute Passwortabfrage — dafür gibt
+ es bereits die App-Sperre aus 13.3.5).
+- [x] **5.1.4** Bearbeiten/Löschen mit Rückfrage (neuer generischer `ConfirmDialog`); Löschen
+ markiert nur `IsDeleted`/`DeletedAt` statt hart zu entfernen —
+ `IDocumentationRepository.Delete`. Ein echtes `HardDelete` existiert separat, nur für die
+ Löschfristen-Bereinigung in 5.4.2.
+- [x] **5.1.5** Zwei weitere Dokumentationstypen ergänzt (Nutzer-Feedback nach Erstauslieferung):
+ - **Elternanruf** (`DocumentationType.ParentCall`): Gesprächspunkte werden im normalen
+ Dialog vorab geplant (`ParentCallData.Points`); ein separater
+ [ParentCallSessionDialog](LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml)
+ ("Gespräch begleiten", Button an jedem Elternanruf-Eintrag) hakt sie während des
+ Telefonats ab und hält Eindrücke/Ergänzungen als Protokoll fest
+ (`ParentCallData.Impressions`, `IsConducted`, `ConductedDate`). Punkte mit
+ unverändertem Text behalten beim erneuten Bearbeiten ihren Abhak-Status (Zuordnung
+ über `ParentCallPoint.Id`, sonst über Textabgleich — ein umbenannter Punkt gilt als
+ neu und startet offen; bewusste Vereinfachung).
+ - **Elternbrief** (`DocumentationType.ParentLetter`): Entwurf/Inhalt, Absendedatum,
+ Rückmeldung erhalten (ja/nein), Rückmeldedatum und -notiz (`ParentLetterData`).
+ - **Datei-Anhänge** (an allen Dokumentationstypen, nicht nur Elternbrief): über LiteDBs
+ eingebauten Dateispeicher (`ILiteStorage`, per Skript verifiziert — funktioniert
+ zuverlässig, im Gegensatz zum defekten `Rebuild`-mit-Passwort aus 13.3.4) —
+ [LiteAttachmentStorage.cs](LehrerApp.Data/LiteAttachmentStorage.cs). Größe strikt auf
+ 10 MB je Datei begrenzt (`IAttachmentStorage.MaxSizeBytes`), damit die Datenbankdatei
+ (und jedes Backup, 13.3.1) nicht durch Anhänge aufgebläht wird. Neu hochgeladene, aber
+ nie gespeicherte Anhänge werden beim Abbrechen des Dialogs wieder gelöscht, damit keine
+ verwaisten Blobs zurückbleiben; beim endgültigen Löschen eines Eintrags (5.4.2) werden
+ auch dessen Anhänge mit entfernt.
+ **Randnotiz:** Beim Schreiben der Tests für den Datei-Speicher fiel eine bereits vorher
+ latent vorhandene Testinfrastruktur-Schwäche auf: `LiteDbContext` nutzt LiteDBs
+ statischen, geteilten `BsonMapper.Global` für die Index-Auflösung — bei paralleler
+ Testausführung über mehrere Testklassen hinweg (xUnit-Standard) führte das sporadisch zu
+ "Member X not found on BsonMapper"-Fehlern in völlig unbeteiligten Tests. Behoben durch
+ `[assembly: CollectionBehavior(DisableTestParallelization = true)]` in
+ [AssemblyInfo.cs](LehrerApp.Data.Tests/AssemblyInfo.cs) — Data-Tests laufen jetzt
+ sequenziell (bei elementaren In-Memory-Tests kein spürbarer Zeitverlust).
+- [x] **5.1.6** Labels zur Nachverfolgung (Nutzer-Feedback): freie Text-Labels an jedem
+ Dokumentationseintrag (`Documentation.Tags`), mit AutoCompleteBox-Vorschlägen
+ (`DocumentationTagDisplay.Suggestions`: Kritisch, Nacharbeiten, Mit JGL abklären,
+ Erkundigung einholen, Elterngespräch nötig, Mit Schulleitung abklären, Klassenkonferenz,
+ Frist beachten, Beobachten, Erledigt — eigene Labels bleiben trotzdem frei möglich).
+ Farbcodierung nach Dringlichkeit statt nach Label-Identität
+ (`DocumentationTagDisplay.ColorHex`): rot = Priorität, orange = Handlungsbedarf,
+ blau = im Blick behalten, grün = abgeschlossen, grau = freies Label. Als farbige Chips in
+ der Dokumentationsliste sichtbar (`DocumentationItem.TagChips`).
+ Dabei außerdem behoben: die Farblegende der Notenentwicklung-Balken (2.5) fehlte sichtbar
+ im UI (nur im Tooltip) — wirkte dadurch wie zufällige/abwechselnde Farbgebung statt wie das
+ eigentliche Signal "Auffälligkeit". Jetzt als kleine Legende über dem Diagramm sichtbar,
+ siehe [StudentDetailView.axaml](LehrerApp.Desktop/Views/Students/StudentDetailView.axaml).
-### 5.2 Fehlzeiten
-- [ ] **5.2.1** Schnelle Abwesenheitserfassung je Stunde (entschuldigt/unentschuldigt).
-- [ ] **5.2.2** Fehlzeitenbilanz je Schüler und Halbjahr (Summe Stunden, Quote).
-- [ ] **5.2.3** Schwellenwert-Warnung (z.B. > 20 % Fehlzeiten) im Schülerdetail und Dashboard.
-- [ ] **5.2.4** Nachträgliches Entschuldigen mit Frist-Hinweis.
+### 5.2 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben)
+- [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über
+ `AttendanceHomeworkQuickInputDialog` und den Mitarbeits-Assistenten (Kapitel 3).
+- [x] **5.2.2** Fehlzeitenbilanz je Schüler und laufendes Schuljahr —
+ [AttendanceBalanceService.cs](LehrerApp.Core/Services/AttendanceBalanceService.cs), reine
+ Auswertungslogik (kontrollierte Stunden, entschuldigt/unentschuldigt/offen, Fehlquote in %),
+ angezeigt im Schülerdetail-Tab "Dokumentation". Schulisch veranlasste Abwesenheit
+ (`OtherSchoolEvent`) zählt bewusst nicht als Fehlzeit des Schülers.
+- [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im
+ Schülerdetail sowie eine neue "Fehlzeiten-Warnung"-Karte im Dashboard (alle Schüler über
+ dem Schwellenwert, sortiert nach Fehlquote).
+- [x] **5.2.4** Nachträgliches Entschuldigen mit Frist-Hinweis — bereits vorhanden über die
+ "Offene Entschuldigungen"-Karte im Dashboard (21-Tage-Grenze, aus Kapitel 3).
### 5.3 Förderpläne
-- [ ] **5.3.1** Förderplan anlegen: Maßnahmenliste, Überprüfungsdatum, Status.
-- [ ] **5.3.2** Wiedervorlage: fällige Überprüfungen erscheinen im Dashboard.
-- [ ] **5.3.3** Verlaufsdokumentation zum Förderplan (mehrere Einträge über die Zeit).
+- [x] **5.3.1** Förderplan anlegen (Maßnahmenliste, Überprüfungsdatum, Status) — über den
+ 5.1-Dialog mit Typ "Förderplan" (`SupportData`: `Measures`, `ReviewDate`, `Status`).
+- [x] **5.3.2** Wiedervorlage: fällige Überprüfungen (Status Aktiv, Überprüfungsdatum in den
+ nächsten 14 Tagen oder überfällig) erscheinen als eigene Dashboard-Karte
+ "Förderplan-Wiedervorlage", überfällige rot hervorgehoben.
+- [x] **5.3.3** Verlaufsdokumentation — die Dokumentationsliste im Schülerdetail zeigt alle
+ Förderplan-Einträge chronologisch; bewusst keine zusätzliche Gruppierung über eine
+ Plan-ID, da das bestehende flache `Documentation`-Modell dafür ausreicht.
### 5.4 Datenschutz
-- [ ] **5.4.1** Vertrauliche Einträge nur nach zusätzlicher Bestätigung anzeigen.
-- [ ] **5.4.2** Löschfristen definieren und abgelaufene Einträge zum Löschen vorschlagen.
-- [ ] **5.4.3** Export einzelner Schülerdaten für Auskunftsersuchen (Art. 15 DSGVO).
+- [x] **5.4.1** Vertrauliche Einträge nur nach zusätzlicher Bestätigung anzeigen — siehe 5.1.3
+ (zusammen umgesetzt, da es sich um dieselbe UI-Stelle handelt).
+- [x] **5.4.2** Löschfristen — neuer Tab "Datenschutz" in den Einstellungen: konfigurierbare
+ Aufbewahrungsfrist in Jahren
+ ([PrivacySettingsService.cs](LehrerApp.Core/Services/PrivacySettingsService.cs), Standard 3
+ Jahre), Liste abgelaufener Einträge zur manuellen Prüfung mit "Endgültig löschen"
+ (`IDocumentationRepository.HardDelete`). Löscht nie automatisch.
+- [x] **5.4.3** Export einzelner Schülerdaten für Auskunftsersuchen (Art. 15 DSGVO) —
+ [PersonalDataExportService.cs](LehrerApp.Core/Services/PersonalDataExportService.cs),
+ JSON-Export mit Stammdaten, Gruppenzuordnungen, Noten, Klausurergebnissen, Mitarbeit und
+ Dokumentation (auch als vertraulich markierte Einträge — das Vertraulich-Kennzeichen blendet
+ nur die laufende Ansicht aus, ist aber keine pauschale rechtliche Ausnahme vom
+ Auskunftsanspruch der betroffenen Person selbst). **Hinweis:** ob im Einzelfall eine
+ Ausnahme greift (z.B. schutzwürdige Belange Dritter nach Landes-Schulrecht), muss die
+ verantwortliche Lehrkraft/Schule selbst prüfen — das ist keine Rechtsberatung.
---
@@ -637,9 +723,12 @@ Fächer- und Kompetenzverwaltung existiert bereits in
Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbeitungsreihenfolge:
-1. **Kapitel 1** (Klausuren) — größte funktionale Lücke, viele andere Punkte hängen daran.
-2. **Kapitel 3.2** (Mitarbeit-Aggregation) — kurz, macht das bestehende Feature erst nutzbar.
-3. **Kapitel 2** (Noten & Zeugnisnoten) — braucht 1 und 3.2 als Datenquellen.
-4. **Kapitel 13.1–13.2** (Tests, Fehlerbehandlung) — bevor die Codebasis weiter wächst.
-5. **Kapitel 4** (Planung) und **Kapitel 5** (Dokumentation) — unabhängig, gut parallelisierbar.
+1. ~~**Kapitel 1** (Klausuren)~~ — erledigt.
+2. ~~**Kapitel 3.2** (Mitarbeit-Aggregation)~~ — erledigt.
+3. ~~**Kapitel 2** (Noten & Zeugnisnoten)~~ — erledigt.
+4. ~~**Kapitel 13** (Technische Basis: Tests, Fehlerbehandlung, Datensicherheit, Codepflege)~~ —
+ erledigt (13.1–13.4 vollständig; 13.4.2 bewusst zurückgestellt, siehe dort).
+5. ~~**Kapitel 5** (Schülerdokumentation)~~ — erledigt (5.2 als Auswertung des bestehenden
+ Anwesenheits-Trackings statt zweiter Erfassung, siehe dort). **Kapitel 4** (Planung) war als
+ parallelisierbar dazu vorgesehen und ist weiterhin offen. **→ nächster sinnvoller Schritt.**
6. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.