using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using System.Collections.ObjectModel; using System.Globalization; using System.Linq; namespace LehrerApp.Desktop.ViewModels.Students; /// Für die Schüler-Auswahl im Dokumentationsdialog, wenn er ohne festen Schüler geöffnet wird /// (Gruppen-Tab, siehe GroupDocumentationTabViewModel). public record StudentOption(Guid Id, string Name); // ── Dokumentation: deutsche Anzeige für Typ/Status (5.1) ───────────────────── public static class DocumentationTypeDisplay { public static string[] Options { get; } = ["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief"]; public static string Label(DocumentationType t) => t switch { DocumentationType.Conversation => "Gespräch", DocumentationType.Incident => "Vorkommnis", DocumentationType.SupportPlan => "Förderplan", DocumentationType.Absence => "Fehlzeit", DocumentationType.ParentCall => "Elternanruf", DocumentationType.ParentLetter => "Elternbrief", _ => "", }; public static DocumentationType FromLabel(string label) => label switch { "Vorkommnis" => DocumentationType.Incident, "Förderplan" => DocumentationType.SupportPlan, "Fehlzeit" => DocumentationType.Absence, "Elternanruf" => DocumentationType.ParentCall, "Elternbrief" => DocumentationType.ParentLetter, _ => DocumentationType.Conversation, }; } // ── Dokumentation: Labels zur Nachverfolgung ───────────────────────────────── public static class DocumentationTagDisplay { // Vorschläge für die AutoCompleteBox im Dialog — eigene Labels sind trotzdem frei möglich. public static string[] Suggestions { get; } = [ "Kritisch", "Nacharbeiten", "Mit JGL abklären", "Erkundigung einholen", "Elterngespräch nötig", "Mit Schulleitung abklären", "Klassenkonferenz", "Frist beachten", "Beobachten", "Erledigt", ]; public static string ColorHex(string tag) => tag switch { "Kritisch" or "Dringend" => "#E53935", // rot – Priorität "Erledigt" => "#43A047", // grün – abgeschlossen "Beobachten" or "Frist beachten" => "#1E88E5", // blau – im Blick behalten "Nacharbeiten" or "Mit JGL abklären" or "Erkundigung einholen" or "Elterngespräch nötig" or "Mit Schulleitung abklären" or "Klassenkonferenz" => "#FB8C00", // orange – Handlungsbedarf _ => "#757575", // grau – freies Label }; } public static class SupportStatusDisplay { public static string[] Options { get; } = ["Aktiv", "Abgeschlossen", "Pausiert"]; public static string Label(SupportStatus s) => s switch { SupportStatus.Completed => "Abgeschlossen", SupportStatus.Paused => "Pausiert", _ => "Aktiv", }; public static SupportStatus FromLabel(string label) => label switch { "Abgeschlossen" => SupportStatus.Completed, "Pausiert" => SupportStatus.Paused, _ => SupportStatus.Active, }; } // ── Dialog: Dokumentationseintrag anlegen/bearbeiten (5.1.1–5.1.3, 5.3.1) ──── public partial class DocumentationDialogViewModel : ObservableObject { private readonly IAttachmentStorage _attachmentStorage; private readonly Documentation? _editing; private readonly Guid _studentId; private readonly Guid? _contextGroupId; private readonly List _newlyUploadedStorageIds = []; // Nur gesetzt, wenn der Dialog aus einem Gruppen-Kontext (5.1, Dokumentation-Tab der // Lerngruppe) ohne festen Schüler geöffnet wird — vom Schüler-Tab aus (fester _studentId) // bleibt die Liste leer und die Auswahl unsichtbar. public List StudentOptions { get; } public bool CanPickStudent => StudentOptions.Count > 0; [ObservableProperty] private StudentOption? _selectedStudent; [ObservableProperty] private string _studentError = ""; [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 bool _excludeFromWebUntisSync; [ObservableProperty] private string _newParticipant = ""; public ObservableCollection Participants { get; } = []; [ObservableProperty] private int _lessonCount = 1; [ObservableProperty] private bool _absenceExcused; [ObservableProperty] private string _absenceReason = ""; [ObservableProperty] private string _newMeasure = ""; public ObservableCollection Measures { get; } = []; [ObservableProperty] private string _reviewDateText = ""; [ObservableProperty] private string _supportStatusName = SupportStatusDisplay.Options[0]; // Elternanruf (nur Planung — Abhaken/Eindrücke laufen über ParentCallSessionDialog) [ObservableProperty] private string _newCallPoint = ""; public ObservableCollection CallPoints { get; } = []; // Elternbrief [ObservableProperty] private string _letterDraftContent = ""; [ObservableProperty] private string _letterSentDateText = ""; [ObservableProperty] private bool _letterResponseReceived; [ObservableProperty] private string _letterResponseDateText = ""; [ObservableProperty] private string _letterResponseNote = ""; [ObservableProperty] private string _letterSentDateError = ""; [ObservableProperty] private string _letterResponseDateError = ""; // Anhänge (für alle Typen) public ObservableCollection Attachments { get; } = []; [ObservableProperty] private string _attachmentError = ""; // Labels zur Nachverfolgung (für alle Typen) [ObservableProperty] private string _newTag = ""; public ObservableCollection Tags { get; } = []; public string[] TagSuggestions => DocumentationTagDisplay.Suggestions; [ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _dateTextError = ""; [ObservableProperty] private string _reviewDateTextError = ""; public bool IsConversation => TypeName == "Gespräch"; public bool IsAbsence => TypeName == "Fehlzeit"; public bool IsSupportPlan => TypeName == "Förderplan"; public bool IsParentCall => TypeName == "Elternanruf"; public bool IsParentLetter => TypeName == "Elternbrief"; public string[] TypeOptions => DocumentationTypeDisplay.Options; public string[] SupportStatusOptions => SupportStatusDisplay.Options; public string DialogTitle => _editing is null ? "Dokumentation hinzufügen" : "Dokumentation bearbeiten"; public long MaxAttachmentSizeBytes => IAttachmentStorage.MaxSizeBytes; public Documentation? Result { get; private set; } public DocumentationDialogViewModel(Guid studentId, Documentation? editing, IAttachmentStorage attachmentStorage, List? studentOptions = null, Guid? contextGroupId = null) { _studentId = studentId; _editing = editing; _attachmentStorage = attachmentStorage; _contextGroupId = contextGroupId; StudentOptions = studentOptions ?? []; if (CanPickStudent) SelectedStudent = StudentOptions.FirstOrDefault(s => s.Id == studentId); 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; ExcludeFromWebUntisSync = editing.ExcludeFromWebUntisSync; 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 = ""; StudentError = ""; LetterSentDateError = ""; LetterResponseDateError = ""; var valid = true; if (CanPickStudent && SelectedStudent is null) { StudentError = "Schüler auswählen."; valid = false; } 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); var effectiveStudentId = CanPickStudent ? SelectedStudent!.Id : _studentId; Result = _editing ?? new Documentation { StudentId = effectiveStudentId, GroupId = _contextGroupId }; Result.Type = type; Result.Date = date; Result.Title = Title.Trim(); Result.Content = (Content ?? "").Trim(); // Das bewusste Speichern im vollständigen Dialog schließt einen im Sitzplan erzeugten // Schnellentwurf ab. Stunden- und Lesson-Bezug bleiben am bestehenden Objekt erhalten. Result.IsDraft = false; Result.IsConfidential = IsConfidential; Result.ExcludeFromWebUntisSync = ExcludeFromWebUntisSync; 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 const string HazardAssessmentSuffix = ".gbu.json"; 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"; /// Strukturierter Gefährdungsbeurteilungs-Anhang (siehe HazardAssessment) statt einer /// beliebigen hochgeladenen Datei — steuert im Anhang-Editor, ob "Öffnen"+"PDF" statt des /// generischen "Speichern" angeboten wird. public bool IsHazardAssessment => FileName.EndsWith(HazardAssessmentSuffix, StringComparison.OrdinalIgnoreCase); } 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 bool IsDraft { get; } public string StatusLabel { get; } public List TagChips { get; } /// Nur im Gruppen-Tab (5.1, GroupDocumentationTabViewModel) gefüllt — die Schüler-Detailansicht /// zeigt ohnehin nur Einträge eines einzelnen Schülers und braucht den Namen nicht. public string StudentName { get; } /// True, wenn der Eintrag keiner Gruppe zugeordnet ist oder der aktuell angezeigten Gruppe /// entspricht — false für Einträge aus einem anderen Unterricht desselben Schülers (werden im /// Gruppen-Tab optisch abgesetzt statt komplett ausgeblendet, siehe Nutzer-Feedback). public bool IsOwnGroup { get; } /// Name der Gruppe, aus der ein nicht-eigener Eintrag stammt (nur gesetzt, wenn !IsOwnGroup). public string OtherGroupLabel { get; } /// Dimmt Einträge aus anderen Lerngruppen im Gruppen-Tab, ohne sie zu verstecken. public double ContentOpacity => IsOwnGroup ? 1.0 : 0.55; [ObservableProperty] private bool _isRevealed; public DocumentationItem(Documentation d, string studentName = "", bool isOwnGroup = true, string otherGroupLabel = "") { 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; IsDraft = d.IsDraft; StatusLabel = BuildStatusLabel(d); TagChips = d.Tags.Select(t => new TagChip(t)).ToList(); StudentName = studentName; IsOwnGroup = isOwnGroup; OtherGroupLabel = otherGroupLabel; } private static string BuildStatusLabel(Documentation d) => d.Type switch { _ when d.IsDraft => "Nacharbeiten", 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; }