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 @@ - + + + + + + + + + +