Schülerdokumentation: Einträge, Fehlzeitenbilanz, Datenschutz (Kapitel 5)

Dokumentationseinträge mit Typwahl (Gespräch, Vorkommnis, Förderplan,
Fehlzeit, Elternanruf, Elternbrief), vertrauliche Einträge nur nach
Bestätigung sichtbar, weiche Löschung mit Nachvollziehbarkeit. Fehlzeiten
als Auswertung des bestehenden Anwesenheits-Trackings statt zweiter
Erfassung, mit Schwellenwert-Warnung im Schülerdetail und Dashboard.
Förderplan-Wiedervorlage als Dashboard-Karte. Datenschutz: Löschfristen
mit manueller Bereinigung und DSGVO-Art.-15-Datenauskunft als Export.

Auf Nutzer-Feedback hin ergänzt: Elternanruf mit begleitendem
Gesprächsprotokoll-Dialog (Punkte abhaken, Eindrücke festhalten),
Elternbrief mit Versand-/Rückmeldungs-Tracking, Datei-Anhänge über
LiteDBs Dateispeicher, frei vergebbare Labels zur Nachverfolgung mit
Dringlichkeits-Farbcodierung, sowie eine sichtbare Farblegende für das
bestehende Notenentwicklungs-Diagramm. Dabei einen Absturz behoben:
leere Textfelder lieferten über das Binding null statt "", was beim
Speichern eine NullReferenceException auslöste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 17:49:03 +02:00
co-authored by Claude Sonnet 5
parent 629b8d1bf0
commit d987b93315
36 changed files with 2395 additions and 62 deletions
@@ -0,0 +1,17 @@
namespace LehrerApp.Core.Interfaces;
/// <summary>
/// Dateianhänge an Dokumentationseinträgen (z.B. der versendete Elternbrief als PDF).
/// Implementiert über den LiteDB-Dateispeicher — kein eigenes Dateisystem-Layout nötig.
/// </summary>
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 <see cref="Models.DocumentAttachment.StorageId"/> zu speichern).
string Upload(string fileName, Stream content);
Stream? OpenRead(string storageId);
void Delete(string storageId);
}
@@ -89,8 +89,13 @@ public interface IDocumentationRepository
{ {
List<Documentation> GetByStudent(Guid studentId); List<Documentation> GetByStudent(Guid studentId);
List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type); List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type);
/// Alle nicht gelöschten Einträge, über alle Schüler hinweg (z.B. für Dashboard-Auswertungen).
List<Documentation> GetAll();
void Save(Documentation doc); void Save(Documentation doc);
/// Markiert den Eintrag als gelöscht, statt ihn hart zu entfernen (5.1.4).
void Delete(Guid id); 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 public interface IWorkTaskRepository
{ {
+45 -1
View File
@@ -12,9 +12,17 @@ public class Documentation
public List<string> Participants { get; set; } = []; public List<string> Participants { get; set; } = [];
public AbsenceData? AbsenceData { get; set; } public AbsenceData? AbsenceData { get; set; }
public SupportData? SupportData { get; set; } public SupportData? SupportData { get; set; }
public ParentCallData? ParentCallData { get; set; }
public ParentLetterData? ParentLetterData { get; set; }
public List<DocumentAttachment> Attachments { get; set; } = [];
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
public List<string> Tags { get; set; } = [];
public bool IsConfidential { get; set; } public bool IsConfidential { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { 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 public class AbsenceData
{ {
@@ -28,7 +36,43 @@ public class SupportData
public DateOnly? ReviewDate { get; set; } public DateOnly? ReviewDate { get; set; }
public SupportStatus Status { get; set; } = SupportStatus.Active; public SupportStatus Status { get; set; } = SupportStatus.Active;
} }
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence } /// <summary>Geplante Gesprächspunkte eines Elternanrufs, abgehakt in <see cref="ParentCallData"/>.</summary>
public class ParentCallPoint
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Text { get; set; } = "";
public bool IsDone { get; set; }
}
/// <summary>
/// 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.
/// </summary>
public class ParentCallData
{
public List<ParentCallPoint> Points { get; set; } = [];
public string Impressions { get; set; } = "";
public bool IsConducted { get; set; }
public DateOnly? ConductedDate { get; set; }
}
/// <summary>Elternbrief: Entwurf/Planung sowie Absende- und Rückmeldedaten.</summary>
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; } = "";
}
/// <summary>Datei-Anhang eines Dokumentationseintrags, im LiteDB-Dateispeicher abgelegt.</summary>
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 enum SupportStatus { Active, Completed, Paused }
public class WorkTask public class WorkTask
@@ -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;
}
/// <summary>
/// Fehlzeitenbilanz (5.2.2/5.2.3) — reine Auswertung der bereits im Mitarbeit-Feature
/// erfassten <see cref="AttendanceStatus"/>-Werte, keine zweite Erfassung.
/// </summary>
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);
}
}
@@ -0,0 +1,56 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Core.Services;
/// <summary>
/// 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.
/// </summary>
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,
});
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
internal class PrivacySettingsConfig
{
public int RetentionYears { get; set; } = 3;
}
/// <summary>
/// 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.
/// </summary>
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<PrivacySettingsConfig>(File.ReadAllText(_configPath))
?? new PrivacySettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new PrivacySettingsConfig();
}
}
+9
View File
@@ -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)]
@@ -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<InvalidOperationException>(() => 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));
}
}
+69
View File
@@ -331,4 +331,73 @@ public sealed class RepositoryTests
Assert.Throws<LiteDB.LiteException>(() => Assert.Throws<LiteDB.LiteException>(() =>
db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = sessionId, StudentId = studentId })); 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));
}
} }
+22
View File
@@ -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);
}
+1
View File
@@ -46,6 +46,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units"); public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons"); public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation"); public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks"); public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries"); public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions"); public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions");
+19 -3
View File
@@ -190,12 +190,28 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository
{ {
public List<Documentation> GetByStudent(Guid id) => public List<Documentation> 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<Documentation> GetByStudentAndType(Guid sid, DocumentationType type) => public List<Documentation> 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(); .OrderByDescending(d => d.Date).ToList();
public List<Documentation> 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 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 public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
@@ -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));
}
}
@@ -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"));
}
}
+38
View File
@@ -110,6 +110,44 @@ public class FakeSchemes : IGradingSchemeRepository
public void Delete(Guid id) { } public void Delete(Guid id) { }
} }
public class FakeGroups(List<LearningGroup> all) : IGroupRepository
{
public LearningGroup? GetById(Guid id) => all.FirstOrDefault(g => g.Id == id);
public List<LearningGroup> GetAll(bool includeInactive = false) => all;
public List<LearningGroup> GetBySchoolYear(string schoolYear, bool includeInactive = false) => all;
public void Save(LearningGroup group) { }
public void Delete(Guid id) { }
}
public class FakeDocumentation : IDocumentationRepository
{
private readonly List<Documentation> _all = [];
public void Add(Documentation d) => _all.Add(d);
public List<Documentation> GetByStudent(Guid studentId) =>
_all.Where(d => d.StudentId == studentId && !d.IsDeleted).ToList();
public List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type) =>
_all.Where(d => d.StudentId == studentId && d.Type == type && !d.IsDeleted).ToList();
public List<Documentation> 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<string, byte[]> _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 public class FakeReportGrades : IReportGradeRepository
{ {
private readonly List<ReportGrade> _all = []; private readonly List<ReportGrade> _all = [];
@@ -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);
}
}
@@ -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<InvalidOperationException>(() => service.ExportAsJson(Guid.NewGuid()));
}
}
+2 -1
View File
@@ -65,9 +65,10 @@ public class App : Application
var gl = Services.GetRequiredService<GroupListViewModel>(); var gl = Services.GetRequiredService<GroupListViewModel>();
gl.OnNavigateToDetail = (id, tab) => main.NavigateToGroupDetail(id, tab); gl.OnNavigateToDetail = (id, tab) => main.NavigateToGroupDetail(id, tab);
// Dashboard → GroupDetail (Chips) // Dashboard → GroupDetail (Chips) / StudentDetail (Fehlzeiten-/Förderplan-Hinweise)
var dash = Services.GetRequiredService<DashboardViewModel>(); var dash = Services.GetRequiredService<DashboardViewModel>();
dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id); dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
// StudentList → StudentDetail + Anlegen // StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>(); var sl = Services.GetRequiredService<StudentListViewModel>();
+4
View File
@@ -99,6 +99,9 @@ public static class AppBootstrapper
services.AddSingleton(backup); services.AddSingleton(backup);
services.AddSingleton(_ => new AppLockService(appData)); services.AddSingleton(_ => new AppLockService(appData));
services.AddSingleton<DatabaseEncryptionService>(); services.AddSingleton<DatabaseEncryptionService>();
services.AddSingleton(_ => new PrivacySettingsService(appData));
services.AddSingleton<AttendanceBalanceService>();
services.AddSingleton<PersonalDataExportService>();
// ── Datenbank ───────────────────────────────────────────────────────── // ── Datenbank ─────────────────────────────────────────────────────────
services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword)); services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
@@ -124,6 +127,7 @@ public static class AppBootstrapper
services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>(); services.AddSingleton<IParticipationSectionRepository, ParticipationSectionRepository>();
services.AddSingleton<ISubjectRepository, SubjectRepository>(); services.AddSingleton<ISubjectRepository, SubjectRepository>();
services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>(); services.AddSingleton<ICompetencyDomainRepository, CompetencyDomainRepository>();
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
@@ -20,9 +20,12 @@ public partial class DashboardViewModel : ObservableObject
private readonly IParticipationSessionRepository _participationSessions; private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participationEntries; private readonly IParticipationRepository _participationEntries;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _sy; private readonly SchoolYearService _sy;
private const int OpenExcuseMaxAgeDays = 21; private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
[ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentDate = "";
@@ -36,18 +39,22 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<GroupChip> CurrentGroups { get; } = []; public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = []; public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = []; public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet // Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; } public Action<Guid>? OnNavigateToGroup { get; set; }
public Action<Guid>? OnNavigateToStudent { get; set; }
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions, 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; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_participationSessions = participationSessions; _participationEntries = participationEntries; _participationSessions = participationSessions; _participationEntries = participationEntries;
_students = students; _sy = sy; _students = students; _documentation = documentation; _attendanceBalance = attendanceBalance; _sy = sy;
Load(); Load();
} }
@@ -90,8 +97,64 @@ public partial class DashboardViewModel : ObservableObject
CalendarMonth = FirstOfMonth(now); CalendarMonth = FirstOfMonth(now);
LoadCalendar(); LoadCalendar();
LoadOpenExcuses(groups.Values.ToList(), today); 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<AttendanceWarningItem>();
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<LearningGroup> groups, DateOnly today) private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{ {
OpenExcuses.Clear(); OpenExcuses.Clear();
@@ -243,6 +306,35 @@ public partial class OpenExcuseItem : ObservableObject
[RelayCommand] private void MarkUnexcused() => OnResolve?.Invoke(this, AttendanceStatus.Unexcused); [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 class CalendarDayCell
{ {
public int DayNumber { get; } public int DayNumber { get; }
@@ -23,6 +23,9 @@ public partial class SettingsViewModel : ObservableObject
private readonly DatabaseEncryptionService _dbEncryption; private readonly DatabaseEncryptionService _dbEncryption;
private readonly AppLockService _appLock; private readonly AppLockService _appLock;
private readonly LiteDbContext _dbContext; private readonly LiteDbContext _dbContext;
private readonly PrivacySettingsService _privacy;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
// ── Fächer ──────────────────────────────────────────────────────────────── // ── Fächer ────────────────────────────────────────────────────────────────
@@ -91,12 +94,23 @@ public partial class SettingsViewModel : ObservableObject
/// ohne dass die App neu gestartet werden muss. /// ohne dass die App neu gestartet werden muss.
public Action? OnAppLockChanged { get; set; } public Action? OnAppLockChanged { get; set; }
// ── Datenschutz: Löschfristen (5.4.2) ────────────────────────────────────
[ObservableProperty] private int _retentionYears = 3;
[ObservableProperty] private string _retentionStatus = "";
public ObservableCollection<ExpiredDocumentItem> ExpiredDocuments { get; } = [];
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
public Func<ExpiredDocumentItem, Task<bool>>? OnConfirmHardDelete { get; set; }
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
AppLockService appLock, LiteDbContext dbContext) AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
IDocumentationRepository documentation, IStudentRepository students)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
@@ -107,6 +121,9 @@ public partial class SettingsViewModel : ObservableObject
_dbEncryption = dbEncryption; _dbEncryption = dbEncryption;
_appLock = appLock; _appLock = appLock;
_dbContext = dbContext; _dbContext = dbContext;
_privacy = privacy;
_documentation = documentation;
_students = students;
LoadSubjects(); LoadSubjects();
LoadGradingKeyTemplates(); LoadGradingKeyTemplates();
LoadGradingSchemes(); LoadGradingSchemes();
@@ -114,6 +131,38 @@ public partial class SettingsViewModel : ObservableObject
IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath); IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath);
AppLockEnabled = _appLock.IsEnabled; AppLockEnabled = _appLock.IsEnabled;
AppLockTimeoutMinutes = _appLock.TimeoutMinutes; 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 ───────────────── // ── 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 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 ───────────────────────────────────────────────────────────────── // ── JSON DTOs ─────────────────────────────────────────────────────────────────
internal class CatalogDto internal class CatalogDto
@@ -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.15.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<string> _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<string> Participants { get; } = [];
[ObservableProperty] private int _lessonCount = 1;
[ObservableProperty] private bool _absenceExcused;
[ObservableProperty] private string _absenceReason = "";
[ObservableProperty] private string _newMeasure = "";
public ObservableCollection<string> 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<string> 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<AttachmentItem> Attachments { get; } = [];
[ObservableProperty] private string _attachmentError = "";
// Labels zur Nachverfolgung (für alle Typen)
[ObservableProperty] private string _newTag = "";
public ObservableCollection<string> 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<TagChip> 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;
}
@@ -0,0 +1,58 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Students;
/// <summary>Ein Gesprächspunkt während des begleiteten Elternanrufs, abhakbar (5.1: Elternanruf).</summary>
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;
}
}
/// <summary>
/// Begleitet einen Elternanruf während des Gesprächs: geplante Punkte abhaken, Eindrücke und
/// Ergänzungen festhalten, als Protokoll speichern.
/// </summary>
public partial class ParentCallSessionViewModel : ObservableObject
{
private readonly Documentation _documentation;
public string StudentTitle { get; }
public ObservableCollection<ParentCallPointItem> 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;
}
}
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -79,6 +80,11 @@ public partial class StudentDetailViewModel : ObservableObject
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults; private readonly IExamResultRepository _examResults;
private readonly IGradeRepository _grades; 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 Student? _student;
[ObservableProperty] private string _studentTitle = ""; [ObservableProperty] private string _studentTitle = "";
@@ -86,23 +92,33 @@ public partial class StudentDetailViewModel : ObservableObject
[ObservableProperty] private string _editFirstName = ""; [ObservableProperty] private string _editFirstName = "";
[ObservableProperty] private string _editLastName = ""; [ObservableProperty] private string _editLastName = "";
[ObservableProperty] private ContactItem? _selectedContact; [ObservableProperty] private ContactItem? _selectedContact;
[ObservableProperty] private AttendanceBalance? _attendance;
[ObservableProperty] private string _exportStatus = "";
public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = []; public ObservableCollection<GroupMembershipEntry> GroupMemberships { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = []; public ObservableCollection<DocumentationItem> Documentation { get; } = [];
public ObservableCollection<ContactItem> Contacts { get; } = []; public ObservableCollection<ContactItem> Contacts { get; } = [];
public ObservableCollection<StudentGradeHistoryGroup> GradeHistory { get; } = []; public ObservableCollection<StudentGradeHistoryGroup> GradeHistory { get; } = [];
public bool HasNoContacts => Contacts.Count == 0; public bool HasNoContacts => Contacts.Count == 0;
public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; } public Func<Contact?, Task<Contact?>>? OnEditContact { get; set; }
public Action<ContactItem>? OnViewAddress { get; set; } public Action<ContactItem>? OnViewAddress { get; set; }
public Func<Guid, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteDocumentation { get; set; }
public Func<string, Task>? OnSaveExportFile { get; set; }
public Func<Documentation, string, Task<Documentation?>>? OnConductParentCall { get; set; }
public StudentDetailViewModel(IStudentRepository students, public StudentDetailViewModel(IStudentRepository students,
IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships, IGroupRepository groups, ISubjectRepository subjects,
IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults, IDocumentationRepository docs, IExamRepository exams, IExamResultRepository examResults,
IGradeRepository grades) IGradeRepository grades, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, AttendanceBalanceService attendanceBalance,
PersonalDataExportService export, SchoolYearService schoolYear)
{ {
_students = students; _memberships = memberships; _students = students; _memberships = memberships;
_groups = groups; _subjects = subjects; _docs = docs; _groups = groups; _subjects = subjects; _docs = docs;
_exams = exams; _examResults = examResults; _grades = grades; _exams = exams; _examResults = examResults; _grades = grades;
_participation = participation; _participationSessions = participationSessions;
_attendanceBalance = attendanceBalance; _export = export; _schoolYear = schoolYear;
} }
public void LoadStudent(Guid id) public void LoadStudent(Guid id)
@@ -127,19 +143,86 @@ public partial class StudentDetailViewModel : ObservableObject
} }
LoadContacts(); LoadContacts();
LoadDocumentation();
LoadAttendanceBalance();
}
// ── Dokumentation (5.1) ───────────────────────────────────────────────────
private void LoadDocumentation()
{
if (Student is null) return;
Documentation.Clear(); Documentation.Clear();
foreach (var d in _docs.GetByStudent(Student.Id)) foreach (var d in _docs.GetByStudent(Student.Id))
Documentation.Add(new() { Date = d.Date.ToString("dd.MM.yyyy"), Title = d.Title, Documentation.Add(new DocumentationItem(d));
TypeLabel = d.Type switch }
[RelayCommand]
private async Task AddDocumentation()
{ {
DocumentationType.Conversation => "Gespräch", if (Student is null || OnEditDocumentation is null) return;
DocumentationType.Incident => "Vorkommnis", var result = await OnEditDocumentation(Student.Id, null);
DocumentationType.SupportPlan => "Förderplan", if (result is null) return;
DocumentationType.Absence => "Fehlzeit", _docs.Save(result);
_ => "", LoadDocumentation();
}, }
IsConfidential = d.IsConfidential });
[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) ──────────────────────────────────────────────── // ── 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 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) ────────────────────────────────────────────────── // ── Notenentwicklung (2.5) ──────────────────────────────────────────────────
@@ -4,6 +4,13 @@
x:Class="LehrerApp.Desktop.Views.Dashboard.DashboardView" x:Class="LehrerApp.Desktop.Views.Dashboard.DashboardView"
x:DataType="vm:DashboardViewModel"> x:DataType="vm:DashboardViewModel">
<UserControl.Styles>
<Style Selector="TextBlock.overdue">
<Setter Property="Foreground" Value="Red"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<ScrollViewer Padding="24"> <ScrollViewer Padding="24">
<StackPanel Spacing="20"> <StackPanel Spacing="20">
@@ -13,7 +20,7 @@
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/> <TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel> </StackPanel>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto"> <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto">
<!-- Heutige Stunden --> <!-- Heutige Stunden -->
<Border Grid.Column="0" Grid.Row="0" Margin="0,0,8,8" <Border Grid.Column="0" Grid.Row="0" Margin="0,0,8,8"
@@ -200,8 +207,67 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Fehlzeiten-Warnung (5.2.3) -->
<Border Grid.Column="0" Grid.Row="2" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FEHLZEITEN-WARNUNG" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding AttendanceWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AttendanceWarningItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<Button Grid.Column="0" Content="{Binding StudentName}" FontSize="13"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentAttendanceCommand}"
CommandParameter="{Binding}"/>
<TextBlock Grid.Column="1" Foreground="Red" FontSize="12" VerticalAlignment="Center">
<Run Text="{Binding AbsenceRatePercent}"/>
<Run Text=" %"/>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Fehlzeiten über dem Schwellenwert." Classes="emptyhint"
IsVisible="{Binding !AttendanceWarnings.Count}"/>
</StackPanel>
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="1" Grid.Row="2" Margin="8,0,0,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="FÖRDERPLAN-WIEDERVORLAGE" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding SupportPlanReviews}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupportPlanDueItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<Button Content="{Binding StudentName}" FontSize="13" FontWeight="SemiBold"
HorizontalAlignment="Left" HorizontalContentAlignment="Left"
Background="Transparent" Padding="0"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenStudentSupportPlanCommand}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding Title}" FontSize="11" Opacity="0.6"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ReviewDateDisplay}" FontSize="12"
VerticalAlignment="Center" Classes.overdue="{Binding IsOverdue}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fälligen Überprüfungen." Classes="emptyhint"
IsVisible="{Binding !SupportPlanReviews.Count}"/>
</StackPanel>
</Border>
<!-- Meine Lerngruppen: wächst mit der Zeit, deshalb ganz unten und volle Breite --> <!-- Meine Lerngruppen: wächst mit der Zeit, deshalb ganz unten und volle Breite -->
<Border Grid.Column="0" Grid.Row="2" Grid.ColumnSpan="2" <Border Grid.Column="0" Grid.Row="3" Grid.ColumnSpan="2"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16"> CornerRadius="8" Padding="16">
<StackPanel> <StackPanel>
@@ -378,6 +378,58 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: Datenschutz (5.4.2) -->
<ContentPage Header="Datenschutz">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<TextBlock Text="Löschfristen für Dokumentationseinträge" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Einträge werden nie automatisch gelöscht — hier abgelaufene Einträge nur zur manuellen Prüfung anzeigen."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="140,Auto">
<NumericUpDown Grid.Column="0" Value="{Binding RetentionYears}" Minimum="1" Maximum="20" FormatString="0"/>
<Button Grid.Column="1" Content="Speichern" Margin="8,0,0,0"
Command="{Binding SaveRetentionYearsCommand}"/>
</Grid>
<TextBlock Text="{Binding RetentionStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding RetentionStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Separator Margin="0,4"/>
<TextBlock Text="Abgelaufene Einträge" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding ExpiredDocuments}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ExpiredDocumentItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13">
<Run Text="{Binding StudentName}"/>
<Run Text=" — "/>
<Run Text="{Binding Title}"/>
</TextBlock>
<TextBlock FontSize="11" Opacity="0.5">
<Run Text="erfasst am "/>
<Run Text="{Binding CreatedAtDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Endgültig löschen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).HardDeleteDocumentCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine abgelaufenen Einträge." Classes="emptyhint"
IsVisible="{Binding !ExpiredDocuments.Count}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -19,6 +19,7 @@ public partial class SettingsView : UserControl
{ {
vm.OnConfirmRestore = ShowRestoreConfirmDialog; vm.OnConfirmRestore = ShowRestoreConfirmDialog;
vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig(); vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig();
vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
} }
} }
@@ -36,6 +37,19 @@ public partial class SettingsView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner); return owner is not null && await dialog.ShowDialog<bool>(owner);
} }
private async Task<bool> 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<bool>(owner);
}
private async void OnImportClick(object? sender, RoutedEventArgs e) private async void OnImportClick(object? sender, RoutedEventArgs e)
{ {
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
@@ -0,0 +1,235 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.DocumentationDialog"
x:DataType="vm:DocumentationDialogViewModel"
Title="{Binding DialogTitle}"
Width="480" SizeToContent="Height" MinHeight="360"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<Grid ColumnDefinitions="*,12,140">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding DateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding DateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Typ *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding TypeOptions}" SelectedItem="{Binding TypeName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Beschreibung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Content}" AcceptsReturn="True" Height="80" TextWrapping="Wrap"/>
</StackPanel>
<!-- Gespräch: Teilnehmer (5.1.2) -->
<StackPanel Spacing="6" IsVisible="{Binding IsConversation}">
<TextBlock Text="Teilnehmer" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding Participants}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding}" VerticalAlignment="Center" FontSize="13"/>
<Button Grid.Column="1" Content="×" Padding="7,2" FontSize="13"
Command="{Binding $parent[ItemsControl].((vm:DocumentationDialogViewModel)DataContext).RemoveParticipantCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewParticipant}" PlaceholderText="Name"/>
<Button Grid.Column="2" Content="" Command="{Binding AddParticipantCommand}"/>
</Grid>
</StackPanel>
<!-- Fehlzeit: Aktenvermerk (5.1.1) -->
<StackPanel Spacing="8" IsVisible="{Binding IsAbsence}">
<TextBlock Text="Freier Aktenvermerk zu einer Abwesenheit (z.B. Krankschreibung eingereicht). Ersetzt nicht die Anwesenheits-Erfassung in der Mitarbeit-Ansicht und fließt nicht in die Fehlzeitenbilanz ein."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="120,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Stunden" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding LessonCount}" Minimum="0" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4" VerticalAlignment="Bottom">
<CheckBox Content="Entschuldigt" IsChecked="{Binding AbsenceExcused}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Grund" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AbsenceReason}"/>
</StackPanel>
</StackPanel>
<!-- Förderplan (5.3.1) -->
<StackPanel Spacing="8" IsVisible="{Binding IsSupportPlan}">
<TextBlock Text="Maßnahmen" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding Measures}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding}" VerticalAlignment="Center" FontSize="13"/>
<Button Grid.Column="1" Content="×" Padding="7,2" FontSize="13"
Command="{Binding $parent[ItemsControl].((vm:DocumentationDialogViewModel)DataContext).RemoveMeasureCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewMeasure}" PlaceholderText="Maßnahme"/>
<Button Grid.Column="2" Content="" Command="{Binding AddMeasureCommand}"/>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Überprüfung am" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding ReviewDateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding ReviewDateTextError}" Foreground="Red" FontSize="11"
IsVisible="{Binding ReviewDateTextError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Status" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding SupportStatusOptions}" SelectedItem="{Binding SupportStatusName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
</StackPanel>
<!-- Elternanruf: Planung (Abhaken/Protokoll läuft separat über "Gespräch begleiten") -->
<StackPanel Spacing="6" IsVisible="{Binding IsParentCall}">
<TextBlock Text="Gesprächspunkte planen. Abgehakt und protokolliert wird während des Anrufs über den Button 'Gespräch begleiten' in der Übersicht."
FontSize="11" Opacity="0.55" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding CallPoints}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding}" VerticalAlignment="Center" FontSize="13"/>
<Button Grid.Column="1" Content="×" Padding="7,2" FontSize="13"
Command="{Binding $parent[ItemsControl].((vm:DocumentationDialogViewModel)DataContext).RemoveCallPointCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewCallPoint}" PlaceholderText="Gesprächspunkt"/>
<Button Grid.Column="2" Content="" Command="{Binding AddCallPointCommand}"/>
</Grid>
</StackPanel>
<!-- Elternbrief (5.1) -->
<StackPanel Spacing="8" IsVisible="{Binding IsParentLetter}">
<StackPanel Spacing="4">
<TextBlock Text="Entwurf / Inhalt" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterDraftContent}" AcceptsReturn="True" Height="70" TextWrapping="Wrap"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Versendet am" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterSentDateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding LetterSentDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding LetterSentDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4" VerticalAlignment="Bottom">
<CheckBox Content="Rückmeldung erhalten" IsChecked="{Binding LetterResponseReceived}"/>
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Rückmeldung am" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterResponseDateText}" PlaceholderText="TT.MM.JJJJ"/>
<TextBlock Text="{Binding LetterResponseDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding LetterResponseDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Rückmeldung (Notiz)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding LetterResponseNote}"/>
</StackPanel>
</Grid>
</StackPanel>
<!-- Anhänge (alle Typen) -->
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Anhänge" FontSize="12" Opacity="0.7" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content=" Datei" FontSize="12" Padding="8,4" Click="OnAddAttachment"/>
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AttachmentItem">
<Grid ColumnDefinitions="*,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding FileName}" VerticalAlignment="Center" FontSize="13"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding SizeDisplay}" VerticalAlignment="Center"
FontSize="11" Opacity="0.5" Margin="6,0"/>
<Button Grid.Column="2" Content="Speichern" FontSize="11" Padding="7,2" Margin="0,0,4,0"
Click="OnOpenAttachment" Tag="{Binding}"/>
<Button Grid.Column="3" Content="×" FontSize="13" Padding="7,2"
Command="{Binding $parent[ItemsControl].((vm:DocumentationDialogViewModel)DataContext).RemoveAttachmentCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding AttachmentError}" Foreground="Red" FontSize="11"
IsVisible="{Binding AttachmentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<!-- Labels zur Nachverfolgung (alle Typen) -->
<StackPanel Spacing="6">
<TextBlock Text="Labels" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="6"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource SystemControlBackgroundBaseMediumBrush}"
CornerRadius="10" Padding="8,3">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="{Binding}" FontSize="11"/>
<Button Content="×" FontSize="11" Padding="0" Background="Transparent"
Command="{Binding $parent[ItemsControl].((vm:DocumentationDialogViewModel)DataContext).RemoveTagCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<AutoCompleteBox Grid.Column="0" Text="{Binding NewTag}"
ItemsSource="{Binding TagSuggestions}"
FilterMode="Contains" MinimumPrefixLength="0"
PlaceholderText="Label (z.B. Kritisch, Nacharbeiten)"/>
<Button Grid.Column="2" Content="" Command="{Binding AddTagCommand}"/>
</Grid>
</StackPanel>
<CheckBox Content="Vertraulich" IsChecked="{Binding IsConfidential}"
ToolTip.Tip="Blendet den Inhalt in der Übersicht standardmäßig aus; erst nach Klick auf 'Anzeigen' sichtbar."/>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -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);
}
}
@@ -0,0 +1,41 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.ParentCallSessionDialog"
x:DataType="vm:ParentCallSessionViewModel"
Title="Elternanruf begleiten"
Width="440" SizeToContent="Height" MinHeight="300"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14">
<TextBlock Text="Elternanruf begleiten" Classes="dialogtitle"/>
<TextBlock Text="{Binding StudentTitle}" FontSize="13" Opacity="0.6"/>
<StackPanel Spacing="6">
<TextBlock Text="Gesprächspunkte" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding Points}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ParentCallPointItem">
<CheckBox Content="{Binding Text}" IsChecked="{Binding IsDone}" Margin="0,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Gesprächspunkte geplant." Classes="emptyhint"
IsVisible="{Binding !Points.Count}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Eindrücke / Ergänzungen" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Impressions}" AcceptsReturn="True" Height="100" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Protokoll speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -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);
}
@@ -131,6 +131,18 @@
<ScrollViewer Padding="20"> <ScrollViewer Padding="20">
<StackPanel Spacing="20"> <StackPanel Spacing="20">
<TextBlock Text="Notenentwicklung" FontSize="15" FontWeight="SemiBold"/> <TextBlock Text="Notenentwicklung" FontSize="15" FontWeight="SemiBold"/>
<StackPanel Orientation="Horizontal" Spacing="16">
<StackPanel Orientation="Horizontal" Spacing="5">
<Border Width="10" Height="10" CornerRadius="2"
Background="{DynamicResource SystemControlBackgroundAccentBrush}"/>
<TextBlock Text="normal" FontSize="11" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="5">
<Border Width="10" Height="10" CornerRadius="2" Background="#E53935"/>
<TextBlock Text="Auffälligkeit (Notenabfall ≥ 1 Note oder mangelhaft/ungenügend)"
FontSize="11" Opacity="0.6"/>
</StackPanel>
</StackPanel>
<ItemsControl ItemsSource="{Binding GradeHistory}"> <ItemsControl ItemsSource="{Binding GradeHistory}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
@@ -160,6 +172,8 @@
Classes.warning="{Binding IsWarning}"/> Classes.warning="{Binding IsWarning}"/>
<TextBlock Text="{Binding Value}" FontSize="11" FontWeight="SemiBold" <TextBlock Text="{Binding Value}" FontSize="11" FontWeight="SemiBold"
HorizontalAlignment="Center" Margin="0,3,0,0"/> HorizontalAlignment="Center" Margin="0,3,0,0"/>
<TextBlock Text="{Binding Label}" FontSize="9" Opacity="0.6"
HorizontalAlignment="Center" TextWrapping="Wrap" TextAlignment="Center"/>
<TextBlock Text="{Binding DateDisplay}" FontSize="9" Opacity="0.5" <TextBlock Text="{Binding DateDisplay}" FontSize="9" Opacity="0.5"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
@@ -188,27 +202,101 @@
<ContentPage Header="Dokumentation"> <ContentPage Header="Dokumentation">
<ScrollViewer Padding="20"> <ScrollViewer Padding="20">
<StackPanel> <StackPanel Spacing="16">
<!-- Fehlzeitenbilanz (5.2.2/5.2.3) — Auswertung der Anwesenheitsdaten aus der
Mitarbeit-Erfassung, keine eigene Erfassung. -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="14,12" IsVisible="{Binding Attendance, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Spacing="6">
<TextBlock Text="Fehlzeitenbilanz (laufendes Schuljahr)" FontSize="13" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.75">
<Run Text="{Binding Attendance.TotalChecked}"/>
<Run Text=" kontrollierte Stunden · "/>
<Run Text="{Binding Attendance.Excused}"/>
<Run Text=" entschuldigt · "/>
<Run Text="{Binding Attendance.Unexcused}"/>
<Run Text=" unentschuldigt · "/>
<Run Text="{Binding Attendance.ExcusePending}"/>
<Run Text=" Entschuldigung offen"/>
</TextBlock>
<TextBlock FontSize="12" Foreground="Red" FontWeight="SemiBold"
IsVisible="{Binding Attendance.ExceedsThreshold}">
<Run Text="Fehlquote "/>
<Run Text="{Binding Attendance.AbsenceRatePercent}"/>
<Run Text=" % — über dem Schwellenwert von 20 %."/>
</TextBlock>
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Einträge" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Datenauskunft exportieren" FontSize="12" Padding="10,4"
Margin="0,0,8,0" Command="{Binding ExportPersonalDataCommand}"
ToolTip.Tip="Alle gespeicherten Daten dieses Schülers als JSON-Datei exportieren (Art. 15 DSGVO)"/>
<Button Grid.Column="2" Content=" Eintrag" Command="{Binding AddDocumentationCommand}"/>
</Grid>
<TextBlock Text="{Binding ExportStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding ExportStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding Documentation}"> <ItemsControl ItemsSource="{Binding Documentation}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:DocEntry"> <DataTemplate DataType="vm:DocumentationItem">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" <Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,10" Margin="0,0,0,8"> CornerRadius="6" Padding="12,10" Margin="0,0,0,8">
<Grid ColumnDefinitions="80,*,Auto"> <StackPanel Spacing="4">
<TextBlock Grid.Column="0" Text="{Binding Date}" Opacity="0.5" FontSize="12"/> <Grid ColumnDefinitions="80,*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" Opacity="0.5" FontSize="12"/>
<StackPanel Grid.Column="1" Margin="8,0"> <StackPanel Grid.Column="1" Margin="8,0">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/> <TextBlock Text="{Binding Model.Title}" FontWeight="SemiBold" FontSize="13"
IsVisible="{Binding IsRevealed}"/>
<TextBlock Text="Vertraulich" FontWeight="SemiBold" FontSize="13" Opacity="0.6"
IsVisible="{Binding !IsRevealed}"/>
<TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/> <TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" Text="🔒" FontSize="14" <TextBlock Grid.Column="2" Text="🔒" FontSize="14" VerticalAlignment="Center"
IsVisible="{Binding IsConfidential}" IsVisible="{Binding IsConfidential}" ToolTip.Tip="Vertraulich"/>
ToolTip.Tip="Vertraulich"/> <Button Grid.Column="3" Content="Anzeigen" FontSize="11" Padding="8,3" Margin="6,0,0,0"
Command="{Binding RevealCommand}" IsVisible="{Binding !IsRevealed}"/>
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="4" Margin="6,0,0,0"
IsVisible="{Binding IsRevealed}">
<Button Content="Gespräch begleiten" FontSize="11" Padding="8,3"
IsVisible="{Binding IsParentCall}"
Command="{Binding $parent[ItemsControl].((vm:StudentDetailViewModel)DataContext).ConductParentCallCommand}"
CommandParameter="{Binding}"/>
<Button Content="Bearbeiten" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:StudentDetailViewModel)DataContext).EditDocumentationCommand}"
CommandParameter="{Binding}"/>
<Button Content="Löschen" FontSize="11" Padding="8,3"
Command="{Binding $parent[ItemsControl].((vm:StudentDetailViewModel)DataContext).DeleteDocumentationCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Grid> </Grid>
<TextBlock Text="{Binding Model.Content}" FontSize="12" TextWrapping="Wrap" Opacity="0.8"
IsVisible="{Binding IsRevealed}"/>
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsRevealed}">
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.55"
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="📎 Anhang" FontSize="11" Opacity="0.55" IsVisible="{Binding HasAttachments}"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TagChips}" IsVisible="{Binding IsRevealed}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel ItemSpacing="6" LineSpacing="4"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TagChip">
<Border Background="{Binding ColorHex}" CornerRadius="10" Padding="8,2">
<TextBlock Text="{Binding Text}" FontSize="10" Foreground="White"/>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<TextBlock Text="Keine Dokumentation vorhanden." Opacity="0.4" </StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Dokumentation vorhanden." Classes="emptyhint"
IsVisible="{Binding !Documentation.Count}"/> IsVisible="{Binding !Documentation.Count}"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -1,7 +1,11 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Students; namespace LehrerApp.Desktop.Views.Students;
@@ -15,6 +19,10 @@ public partial class StudentDetailView : UserControl
if (DataContext is not StudentDetailViewModel vm) return; if (DataContext is not StudentDetailViewModel vm) return;
vm.OnEditContact = ShowContactDialog; vm.OnEditContact = ShowContactDialog;
vm.OnViewAddress = ShowAddressViewer; vm.OnViewAddress = ShowAddressViewer;
vm.OnEditDocumentation = ShowDocumentationDialog;
vm.OnConfirmDeleteDocumentation = ShowDeleteDocumentationDialog;
vm.OnSaveExportFile = SaveExportFile;
vm.OnConductParentCall = ShowParentCallSessionDialog;
} }
private async Task<Contact?> ShowContactDialog(Contact? contact) private async Task<Contact?> ShowContactDialog(Contact? contact)
@@ -45,4 +53,57 @@ public partial class StudentDetailView : UserControl
vm.ViewSelectedAddressCommand.CanExecute(null)) vm.ViewSelectedAddressCommand.CanExecute(null))
vm.ViewSelectedAddressCommand.Execute(null); vm.ViewSelectedAddressCommand.Execute(null);
} }
private async Task<Documentation?> 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<IAttachmentStorage>());
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
return saved ? vm.Result : null;
}
private async Task<Documentation?> 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<bool>(owner);
return saved ? vm.Result : null;
}
private async Task<bool> 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<bool>(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);
}
} }
@@ -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);
}
}
@@ -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); }
}
}
+115 -26
View File
@@ -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; 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 Bereiche ohne Werte werden bei der Berechnung ausgelassen und die verbleibenden Prozentanteile neu normiert
(`GradingService.CalculateReportGrade()`). Notenentwicklung im Schülerdetail zeigt ein einfaches (`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 ## 5. Schülerdokumentation
Modelle `Documentation`, `AbsenceData`, `SupportData` existieren, Repository ebenfalls. **Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
Der Tab "Dokumentation" in der Gruppenansicht ist ein Platzhalter, im Schülerdetail dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
werden `DocEntry`-Einträge bereits gelesen. 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 Einträge erfassen
- [ ] **5.1.1** Dialog "Dokumentation hinzufügen" mit Typwahl - [x] **5.1.1** Dialog "Dokumentation hinzufügen" mit Typwahl
(`Conversation`, `Incident`, `SupportPlan`, `Absence`) und typabhängigen Feldern. (`Conversation`, `Incident`, `SupportPlan`, `Absence`) und typabhängigen Feldern
- [ ] **5.1.2** Teilnehmerliste (`Participants`) bei Gesprächen erfassen. [DocumentationDialog.axaml](LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml),
- [ ] **5.1.3** Kennzeichen "vertraulich" (`IsConfidential`) mit Ausblenden in Übersichten. [DocumentationViewModels.cs](LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs).
- [ ] **5.1.4** Bearbeiten/Löschen mit Rückfrage; gelöschte Einträge nicht hart entfernen, Deutsche Anzeige über `DocumentationTypeDisplay`/`SupportStatusDisplay`, analog zum
sondern als gelöscht markieren (Nachvollziehbarkeit). `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<string>`, 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 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben)
- [ ] **5.2.1** Schnelle Abwesenheitserfassung je Stunde (entschuldigt/unentschuldigt). - [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über
- [ ] **5.2.2** Fehlzeitenbilanz je Schüler und Halbjahr (Summe Stunden, Quote). `AttendanceHomeworkQuickInputDialog` und den Mitarbeits-Assistenten (Kapitel 3).
- [ ] **5.2.3** Schwellenwert-Warnung (z.B. > 20 % Fehlzeiten) im Schülerdetail und Dashboard. - [x] **5.2.2** Fehlzeitenbilanz je Schüler und laufendes Schuljahr —
- [ ] **5.2.4** Nachträgliches Entschuldigen mit Frist-Hinweis. [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 Förderpläne
- [ ] **5.3.1** Förderplan anlegen: Maßnahmenliste, Überprüfungsdatum, Status. - [x] **5.3.1** Förderplan anlegen (Maßnahmenliste, Überprüfungsdatum, Status) — über den
- [ ] **5.3.2** Wiedervorlage: fällige Überprüfungen erscheinen im Dashboard. 5.1-Dialog mit Typ "Förderplan" (`SupportData`: `Measures`, `ReviewDate`, `Status`).
- [ ] **5.3.3** Verlaufsdokumentation zum Förderplan (mehrere Einträge über die Zeit). - [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 Datenschutz
- [ ] **5.4.1** Vertrauliche Einträge nur nach zusätzlicher Bestätigung anzeigen. - [x] **5.4.1** Vertrauliche Einträge nur nach zusätzlicher Bestätigung anzeigen — siehe 5.1.3
- [ ] **5.4.2** Löschfristen definieren und abgelaufene Einträge zum Löschen vorschlagen. (zusammen umgesetzt, da es sich um dieselbe UI-Stelle handelt).
- [ ] **5.4.3** Export einzelner Schülerdaten für Auskunftsersuchen (Art. 15 DSGVO). - [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: Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbeitungsreihenfolge:
1. **Kapitel 1** (Klausuren) — größte funktionale Lücke, viele andere Punkte hängen daran. 1. ~~**Kapitel 1** (Klausuren)~~ — erledigt.
2. **Kapitel 3.2** (Mitarbeit-Aggregation) — kurz, macht das bestehende Feature erst nutzbar. 2. ~~**Kapitel 3.2** (Mitarbeit-Aggregation)~~ — erledigt.
3. **Kapitel 2** (Noten & Zeugnisnoten) — braucht 1 und 3.2 als Datenquellen. 3. ~~**Kapitel 2** (Noten & Zeugnisnoten)~~ — erledigt.
4. **Kapitel 13.113.2** (Tests, Fehlerbehandlung) — bevor die Codebasis weiter wächst. 4. ~~**Kapitel 13** (Technische Basis: Tests, Fehlerbehandlung, Datensicherheit, Codepflege)~~
5. **Kapitel 4** (Planung) und **Kapitel 5** (Dokumentation) — unabhängig, gut parallelisierbar. erledigt (13.113.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. 6. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.